feat: integrate toast notifications in settings and layout components

- Added ToastService for managing toast notifications.
- Replaced alert in settings component with toast notifications for success and error messages.
- Included ToastComponent in layout for displaying notifications.
- Created loading spinner component for better user experience.
- Implemented domain detail component with detailed views for certificates, requirements, and services.
- Added functionality to manage and display SSL certificates and their statuses.
- Introduced a registry manager class for handling Docker registry operations.
This commit is contained in:
2025-11-24 01:31:15 +00:00
parent b6ac4f209a
commit c9beae93c8
23 changed files with 2475 additions and 130 deletions

View File

@@ -57,9 +57,16 @@ export const routes: Routes = [
import('./features/dns/dns.component').then((m) => m.DnsComponent),
},
{
path: 'ssl',
path: 'domains',
loadComponent: () =>
import('./features/ssl/ssl.component').then((m) => m.SslComponent),
import('./features/domains/domains.component').then((m) => m.DomainsComponent),
},
{
path: 'domains/:domain',
loadComponent: () =>
import('./features/domains/domain-detail.component').then(
(m) => m.DomainDetailComponent
),
},
{
path: 'settings',

View File

@@ -21,6 +21,13 @@ export interface Service {
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
createdAt: number;
updatedAt: number;
// Onebox Registry fields
useOneboxRegistry?: boolean;
registryRepository?: string;
registryToken?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
}
export interface Registry {
@@ -106,6 +113,16 @@ export class ApiService {
return this.http.get<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
}
updateService(name: string, updates: {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}): Observable<ApiResponse<Service>> {
return this.http.put<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`, updates);
}
// Registries
getRegistries(): Observable<ApiResponse<Registry[]>> {
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
@@ -132,6 +149,10 @@ export class ApiService {
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
}
syncDnsRecords(): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/dns/sync`, {});
}
// SSL
getSslCertificates(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/ssl`);
@@ -141,6 +162,19 @@ export class ApiService {
return this.http.post<ApiResponse>(`${this.baseUrl}/ssl/${domain}/renew`, {});
}
// Domains
getDomains(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/domains`);
}
getDomainDetail(domain: string): Observable<ApiResponse<any>> {
return this.http.get<ApiResponse<any>>(`${this.baseUrl}/domains/${domain}`);
}
syncCloudflareDomains(): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/domains/sync`, {});
}
// Settings
getSettings(): Observable<ApiResponse<Record<string, string>>> {
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);

View File

@@ -0,0 +1,53 @@
import { Injectable, signal } from '@angular/core';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
@Injectable({
providedIn: 'root'
})
export class ToastService {
toasts = signal<Toast[]>([]);
private nextId = 0;
show(type: ToastType, message: string, duration: number = 5000) {
const id = `toast-${this.nextId++}`;
const toast: Toast = { id, type, message, duration };
this.toasts.update(toasts => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => this.remove(id), duration);
}
}
success(message: string, duration?: number) {
this.show('success', message, duration);
}
error(message: string, duration?: number) {
this.show('error', message, duration);
}
info(message: string, duration?: number) {
this.show('info', message, duration);
}
warning(message: string, duration?: number) {
this.show('warning', message, duration);
}
remove(id: string) {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
clear() {
this.toasts.set([]);
}
}

View File

@@ -1,7 +1,9 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ApiService, SystemStatus } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-dashboard',
@@ -9,7 +11,22 @@ import { ApiService, SystemStatus } from '../../core/services/api.service';
imports: [CommonModule, RouterLink],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h1>
<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>
</div>
@if (loading()) {
<div class="text-center py-12">
@@ -169,14 +186,38 @@ import { ApiService, SystemStatus } from '../../core/services/api.service';
</div>
`,
})
export class DashboardComponent implements OnInit {
export class DashboardComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
status = signal<SystemStatus | null>(null);
loading = signal(true);
lastUpdated = signal<Date | null>(null);
private wsSubscription?: Subscription;
private refreshInterval?: number;
ngOnInit(): void {
this.loadStatus();
// 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') {
this.loadStatus();
}
});
// Auto-refresh every 30 seconds
this.refreshInterval = window.setInterval(() => {
this.loadStatus();
}, 30000);
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
loadStatus(): void {
@@ -185,6 +226,7 @@ export class DashboardComponent implements OnInit {
next: (response) => {
if (response.success && response.data) {
this.status.set(response.data);
this.lastUpdated.set(new Date());
}
this.loading.set(false);
},
@@ -193,4 +235,8 @@ export class DashboardComponent implements OnInit {
},
});
}
refresh(): void {
this.loadStatus();
}
}

View File

@@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-dns',
@@ -9,7 +10,16 @@ import { ApiService } from '../../core/services/api.service';
imports: [CommonModule, FormsModule],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">DNS Records</h1>
<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' }}
</button>
</div>
@if (records().length > 0) {
<div class="card overflow-hidden p-0">
@@ -40,6 +50,7 @@ import { ApiService } from '../../core/services/api.service';
<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>
@@ -47,7 +58,9 @@ import { ApiService } from '../../core/services/api.service';
})
export class DnsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
records = signal<any[]>([]);
syncing = signal(false);
ngOnInit(): void {
this.loadRecords();
@@ -63,6 +76,25 @@ export class DnsComponent implements OnInit {
});
}
syncRecords(): 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);
},
});
}
deleteRecord(record: any): void {
if (confirm(`Delete DNS record for ${record.domain}?`)) {
this.apiService.deleteDnsRecord(record.domain).subscribe({

View File

@@ -0,0 +1,356 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
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;
}>;
}
@Component({
selector: 'app-domain-detail',
standalone: true,
imports: [CommonModule, RouterLink],
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>
@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>
</div>
}
</div>
</div>
`,
})
export class DomainDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
domainDetail = signal<DomainDetail | null>(null);
loading = signal(true);
ngOnInit(): void {
const domain = this.route.snapshot.paramMap.get('domain');
if (domain) {
this.loadDomainDetail(domain);
}
}
loadDomainDetail(domain: string): 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);
},
});
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
getDaysRemaining(expiryDate: number): number {
const now = Date.now();
const diff = expiryDate - now;
return Math.floor(diff / (24 * 60 * 60 * 1000));
}
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';
}
getServiceName(serviceId: number): string {
const service = this.domainDetail()?.services.find((s) => s.id === serviceId);
return service?.name || `Service #${serviceId}`;
}
}

View File

@@ -1,86 +1,216 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
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[];
}
@Component({
selector: 'app-ssl',
selector: 'app-domains',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, RouterLink],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">SSL Certificates</h1>
<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' }}
</button>
</div>
@if (certificates().length > 0) {
@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">Issuer</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 (cert of certificates(); track cert.domain) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.domain }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.issuer }}</td>
@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">
<span [ngClass]="isExpiringSoon(cert.expiryDate) ? 'text-red-600' : 'text-gray-500'">
{{ formatDate(cert.expiryDate) }}
</span>
@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">
<button (click)="renewCertificate(cert)" class="text-primary-600 hover:text-primary-900">Renew</button>
<a
[routerLink]="['/domains', domainView.domain.domain]"
class="text-primary-600 hover:text-primary-900"
>
View Details
</a>
</td>
</tr>
}
</tbody>
</table>
</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 SSL certificates</p>
<p class="text-sm text-gray-400 mt-2">Certificates are obtained automatically when deploying services with domains</p>
<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>
}
</div>
`,
})
export class SslComponent implements OnInit {
export class DomainsComponent implements OnInit {
private apiService = inject(ApiService);
certificates = signal<any[]>([]);
private toastService = inject(ToastService);
domains = signal<DomainView[]>([]);
loading = signal(true);
syncing = signal(false);
ngOnInit(): void {
this.loadCertificates();
this.loadDomains();
}
loadCertificates(): void {
this.apiService.getSslCertificates().subscribe({
loadDomains(): void {
this.loading.set(true);
this.apiService.getDomains().subscribe({
next: (response) => {
if (response.success && response.data) {
this.certificates.set(response.data);
this.domains.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
renewCertificate(cert: any): void {
this.apiService.renewSslCertificate(cert.domain).subscribe({
next: () => {
alert('Certificate renewal initiated');
this.loadCertificates();
syncDomains(): 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);
},
});
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
isExpiringSoon(timestamp: number): boolean {
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
return timestamp - Date.now() < thirtyDays;
getStatusCount(status: string): number {
return this.domains().filter(d => d.certificateStatus === status).length;
}
}

View File

@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
interface EnvVar {
@@ -9,10 +9,16 @@ interface EnvVar {
value: string;
}
interface Domain {
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
}
@Component({
selector: 'app-service-create',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, RouterLink],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
@@ -42,13 +48,66 @@ interface EnvVar {
id="image"
[(ngModel)]="image"
name="image"
required
[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>
</div>
}
</div>
<!-- Port -->
<div class="mb-6">
<label for="port" class="label">Container Port *</label>
@@ -71,11 +130,48 @@ interface EnvVar {
type="text"
id="domain"
[(ngModel)]="domain"
(ngModelChange)="onDomainChange()"
name="domain"
placeholder="app.example.com"
list="domainList"
class="input"
[class.border-red-300]="domainWarning()"
/>
<p class="mt-1 text-sm text-gray-500">Leave empty to skip automatic DNS & SSL</p>
<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>
} @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>
}
</p>
}
</div>
<!-- Environment Variables -->
@@ -155,7 +251,7 @@ interface EnvVar {
</div>
`,
})
export class ServiceCreateComponent {
export class ServiceCreateComponent implements OnInit {
private apiService = inject(ApiService);
private router = inject(Router);
@@ -169,6 +265,77 @@ export class ServiceCreateComponent {
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('');
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);
}
}
addEnvVar(): void {
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
}
@@ -197,6 +364,9 @@ export class ServiceCreateComponent {
envVars: envVarsObj,
autoDNS: this.autoDNS,
autoSSL: this.autoSSL,
useOneboxRegistry: this.useOneboxRegistry,
registryImageTag: this.useOneboxRegistry ? this.registryImageTag : undefined,
autoUpdateOnPush: this.useOneboxRegistry ? this.autoUpdateOnPush : undefined,
};
this.apiService.createService(data).subscribe({

View File

@@ -1,12 +1,25 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService, Service } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
interface EnvVar {
key: string;
value: string;
}
interface Domain {
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
}
@Component({
selector: 'app-service-detail',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FormsModule, RouterLink],
template: `
<div class="px-4 sm:px-0">
@if (loading()) {
@@ -29,109 +42,383 @@ import { ApiService, Service } from '../../core/services/api.service';
<!-- Details Card -->
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Service Details</h2>
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Image</dt>
<dd class="mt-1 text-sm text-gray-900">{{ service()!.image }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Port</dt>
<dd class="mt-1 text-sm text-gray-900">{{ service()!.port }}</dd>
</div>
@if (service()!.domain) {
<div>
<dt class="text-sm font-medium text-gray-500">Domain</dt>
<dd class="mt-1 text-sm text-gray-900">
<a [href]="'https://' + service()!.domain" target="_blank" class="text-primary-600 hover:text-primary-900">
{{ service()!.domain }}
</a>
</dd>
</div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Service Details</h2>
@if (!isEditing()) {
<button (click)="startEditing()" class="btn btn-secondary text-sm">
Edit Service
</button>
}
@if (service()!.containerID) {
<div>
<dt class="text-sm font-medium text-gray-500">Container ID</dt>
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ service()!.containerID?.substring(0, 12) }}</dd>
</div>
}
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.createdAt) }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Updated</dt>
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.updatedAt) }}</dd>
</div>
</dl>
</div>
<!-- Environment Variables -->
@if (Object.keys(service()!.envVars).length > 0) {
<div class="mt-6">
<h3 class="text-sm font-medium text-gray-500 mb-2">Environment Variables</h3>
<div class="bg-gray-50 rounded-md p-4">
@for (entry of Object.entries(service()!.envVars); track entry[0]) {
<div class="flex justify-between py-1">
<span class="text-sm font-mono text-gray-700">{{ entry[0] }}</span>
<span class="text-sm font-mono text-gray-900">{{ entry[1] }}</span>
@if (!isEditing()) {
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Image</dt>
<dd class="mt-1 text-sm text-gray-900">{{ service()!.image }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Port</dt>
<dd class="mt-1 text-sm text-gray-900">{{ service()!.port }}</dd>
</div>
@if (service()!.domain) {
<div>
<dt class="text-sm font-medium text-gray-500">Domain</dt>
<dd class="mt-1 text-sm text-gray-900">
<a [href]="'https://' + service()!.domain" target="_blank" class="text-primary-600 hover:text-primary-900">
{{ service()!.domain }}
</a>
</dd>
</div>
}
@if (service()!.containerID) {
<div>
<dt class="text-sm font-medium text-gray-500">Container ID</dt>
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ service()!.containerID?.substring(0, 12) }}</dd>
</div>
}
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.createdAt) }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Updated</dt>
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.updatedAt) }}</dd>
</div>
</dl>
<!-- Registry Information -->
@if (service()!.useOneboxRegistry) {
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-sm font-semibold text-blue-900 mb-3">Onebox Registry</h3>
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-blue-700">Repository</dt>
<dd class="mt-1 text-sm text-blue-900 font-mono">{{ service()!.registryRepository }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-blue-700">Tag</dt>
<dd class="mt-1 text-sm text-blue-900">{{ service()!.registryImageTag || 'latest' }}</dd>
</div>
@if (service()!.registryToken) {
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-blue-700">Push/Pull Token</dt>
<dd class="mt-1">
<div class="flex items-center gap-2">
<input
type="password"
[value]="service()!.registryToken"
readonly
class="input text-xs font-mono flex-1"
#tokenInput
/>
<button
type="button"
(click)="copyToken(tokenInput.value)"
class="btn btn-secondary text-xs"
>
Copy
</button>
</div>
<p class="mt-1 text-xs text-blue-600">
Use this token to push images: <code class="bg-blue-100 px-1 py-0.5 rounded">docker login -u unused -p [token] {{ registryBaseUrl() }}</code>
</p>
</dd>
</div>
}
<div>
<dt class="text-sm font-medium text-blue-700">Auto-update</dt>
<dd class="mt-1 text-sm text-blue-900">
{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}
</dd>
</div>
@if (service()!.imageDigest) {
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-blue-700">Current Digest</dt>
<dd class="mt-1 text-xs text-blue-900 font-mono break-all">{{ service()!.imageDigest }}</dd>
</div>
}
</dl>
</div>
}
<!-- Environment Variables -->
@if (Object.keys(service()!.envVars).length > 0) {
<div class="mt-6">
<h3 class="text-sm font-medium text-gray-500 mb-2">Environment Variables</h3>
<div class="bg-gray-50 rounded-md p-4">
@for (entry of Object.entries(service()!.envVars); track entry[0]) {
<div class="flex justify-between py-1">
<span class="text-sm font-mono text-gray-700">{{ entry[0] }}</span>
<span class="text-sm font-mono text-gray-900">{{ entry[1] }}</span>
</div>
}
</div>
</div>
}
} @else {
<!-- Edit Form -->
<form (ngSubmit)="saveService()">
<!-- Image -->
<div class="mb-6">
<label for="edit-image" class="label">Docker Image *</label>
<input
type="text"
id="edit-image"
[(ngModel)]="editForm.image"
name="image"
required
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Port -->
<div class="mb-6">
<label for="edit-port" class="label">Container Port *</label>
<input
type="number"
id="edit-port"
[(ngModel)]="editForm.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="edit-domain" class="label">Domain (Optional)</label>
<input
type="text"
id="edit-domain"
[(ngModel)]="editForm.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>
} @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>
}
</p>
}
</div>
</div>
<!-- Environment Variables -->
<div class="mb-6">
<label class="label">Environment Variables</label>
@for (env of editEnvVars(); 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
</button>
</div>
}
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
Add Environment Variable
</button>
</div>
@if (error()) {
<div class="rounded-md bg-red-50 p-4 mb-6">
<p class="text-sm text-red-800">{{ error() }}</p>
</div>
}
<!-- Edit Actions -->
<div class="flex justify-end space-x-4">
<button type="button" (click)="cancelEditing()" class="btn btn-secondary" [disabled]="saving()">
Cancel
</button>
<button type="submit" class="btn btn-primary" [disabled]="saving()">
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
}
</div>
<!-- Actions -->
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Actions</h2>
<div class="flex space-x-4">
@if (service()!.status === 'stopped') {
<button (click)="startService()" class="btn btn-success">Start</button>
}
@if (service()!.status === 'running') {
<button (click)="stopService()" class="btn btn-secondary">Stop</button>
<button (click)="restartService()" class="btn btn-primary">Restart</button>
}
<button (click)="deleteService()" class="btn btn-danger">Delete</button>
@if (!isEditing()) {
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Actions</h2>
<div class="flex space-x-4">
@if (service()!.status === 'stopped') {
<button (click)="startService()" class="btn btn-success">Start</button>
}
@if (service()!.status === 'running') {
<button (click)="stopService()" class="btn btn-secondary">Stop</button>
<button (click)="restartService()" class="btn btn-primary">Restart</button>
}
<button (click)="deleteService()" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
}
<!-- Logs -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
<button (click)="refreshLogs()" class="btn btn-secondary text-sm">Refresh</button>
@if (!isEditing()) {
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
<div class="flex items-center gap-3">
<!-- Search -->
<input
type="text"
[(ngModel)]="logSearch"
(ngModelChange)="filterLogs()"
placeholder="Search logs..."
class="input text-sm w-48"
/>
<!-- Log Level Filter -->
<select [(ngModel)]="logLevelFilter" (ngModelChange)="filterLogs()" class="input text-sm">
<option value="all">All Levels</option>
<option value="error">Errors</option>
<option value="warn">Warnings</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
<!-- Auto-refresh toggle -->
<label class="flex items-center text-sm text-gray-700">
<input
type="checkbox"
[(ngModel)]="logsAutoRefresh"
(ngModelChange)="toggleLogsAutoRefresh()"
class="mr-2"
/>
Auto-refresh
</label>
<button (click)="refreshLogs()" class="btn btn-secondary text-sm" [disabled]="loadingLogs()">
<svg class="w-4 h-4" [class.animate-spin]="loadingLogs()" 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>
</button>
</div>
</div>
@if (loadingLogs()) {
<div class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
} @else {
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto max-h-96 overflow-y-auto">
@if (filteredLogs().length === 0) {
<p class="text-sm text-gray-400">No logs available</p>
} @else {
@for (line of filteredLogs(); track $index) {
<div class="text-xs font-mono mb-1" [ngClass]="{
'text-red-400': isLogLevel(line, 'error'),
'text-yellow-400': isLogLevel(line, 'warn'),
'text-blue-300': isLogLevel(line, 'info'),
'text-gray-400': isLogLevel(line, 'debug'),
'text-gray-100': !hasLogLevel(line)
}">{{ line }}</div>
}
}
</div>
}
@if (filteredLogs().length > 0 && filteredLogs().length !== logLines().length) {
<div class="mt-2 text-sm text-gray-500">
Showing {{ filteredLogs().length }} of {{ logLines().length }} lines
</div>
}
</div>
@if (loadingLogs()) {
<div class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
} @else {
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto">
<pre class="text-xs text-gray-100 font-mono">{{ logs() || 'No logs available' }}</pre>
</div>
}
</div>
}
}
</div>
`,
})
export class ServiceDetailComponent implements OnInit {
export class ServiceDetailComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
service = signal<Service | null>(null);
logs = signal('');
logLines = signal<string[]>([]);
filteredLogs = signal<string[]>([]);
logSearch = '';
logLevelFilter = 'all';
logsAutoRefresh = false;
private logsRefreshInterval?: number;
loading = signal(true);
loadingLogs = signal(false);
// Edit mode
isEditing = signal(false);
saving = signal(false);
error = signal('');
editForm = {
image: '',
port: 80,
domain: '',
};
editEnvVars = signal<EnvVar[]>([]);
// Domain validation
availableDomains = signal<Domain[]>([]);
domainWarning = signal(false);
domainWarningTitle = signal('');
domainWarningMessage = signal('');
Object = Object;
ngOnInit(): void {
const name = this.route.snapshot.paramMap.get('name')!;
this.loadService(name);
this.loadLogs(name);
this.loadDomains();
}
loadService(name: string): void {
@@ -156,6 +443,9 @@ export class ServiceDetailComponent implements OnInit {
next: (response) => {
if (response.success && response.data) {
this.logs.set(response.data);
const lines = response.data.split('\n').filter((line: string) => line.trim());
this.logLines.set(lines);
this.filterLogs();
}
this.loadingLogs.set(false);
},
@@ -165,6 +455,174 @@ export class ServiceDetailComponent implements OnInit {
});
}
filterLogs(): void {
let lines = this.logLines();
// Apply level filter
if (this.logLevelFilter !== 'all') {
lines = lines.filter(line => this.isLogLevel(line, this.logLevelFilter));
}
// Apply search filter
if (this.logSearch.trim()) {
const searchLower = this.logSearch.toLowerCase();
lines = lines.filter(line => line.toLowerCase().includes(searchLower));
}
this.filteredLogs.set(lines);
}
isLogLevel(line: string, level: string): boolean {
const lineLower = line.toLowerCase();
if (level === 'error') return lineLower.includes('error') || lineLower.includes('✖');
if (level === 'warn') return lineLower.includes('warn') || lineLower.includes('warning');
if (level === 'info') return lineLower.includes('info') || lineLower.includes('');
if (level === 'debug') return lineLower.includes('debug');
return false;
}
hasLogLevel(line: string): boolean {
return this.isLogLevel(line, 'error') ||
this.isLogLevel(line, 'warn') ||
this.isLogLevel(line, 'info') ||
this.isLogLevel(line, 'debug');
}
toggleLogsAutoRefresh(): void {
if (this.logsAutoRefresh) {
this.logsRefreshInterval = window.setInterval(() => {
this.refreshLogs();
}, 5000); // Refresh every 5 seconds
} else {
if (this.logsRefreshInterval) {
clearInterval(this.logsRefreshInterval);
this.logsRefreshInterval = undefined;
}
}
}
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
},
});
}
startEditing(): void {
const svc = this.service()!;
this.editForm.image = svc.image;
this.editForm.port = svc.port;
this.editForm.domain = svc.domain || '';
// Convert env vars to array
const envVars: EnvVar[] = [];
for (const [key, value] of Object.entries(svc.envVars || {})) {
envVars.push({ key, value });
}
this.editEnvVars.set(envVars);
this.isEditing.set(true);
this.error.set('');
}
cancelEditing(): void {
this.isEditing.set(false);
this.error.set('');
this.domainWarning.set(false);
}
saveService(): void {
this.error.set('');
this.saving.set(true);
// Convert env vars to object
const envVarsObj: Record<string, string> = {};
for (const env of this.editEnvVars()) {
if (env.key && env.value) {
envVarsObj[env.key] = env.value;
}
}
const updates = {
image: this.editForm.image,
port: this.editForm.port,
domain: this.editForm.domain || undefined,
envVars: envVarsObj,
};
this.apiService.updateService(this.service()!.name, updates).subscribe({
next: (response) => {
this.saving.set(false);
if (response.success) {
this.service.set(response.data!);
this.isEditing.set(false);
} else {
this.error.set(response.error || 'Failed to update service');
}
},
error: (err) => {
this.saving.set(false);
this.error.set(err.error?.error || 'An error occurred');
},
});
}
addEnvVar(): void {
this.editEnvVars.update((vars) => [...vars, { key: '', value: '' }]);
}
removeEnvVar(index: number): void {
this.editEnvVars.update((vars) => vars.filter((_, i) => i !== index));
}
onDomainChange(): void {
if (!this.editForm.domain) {
this.domainWarning.set(false);
return;
}
// Extract base domain from entered domain
const parts = this.editForm.domain.split('.');
if (parts.length < 2) {
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 update, 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);
}
}
refreshLogs(): void {
this.loadLogs(this.service()!.name);
}
@@ -206,4 +664,22 @@ export class ServiceDetailComponent implements OnInit {
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
private toastService = inject(ToastService);
copyToken(token: string): void {
navigator.clipboard.writeText(token).then(() => {
this.toastService.success('Token copied to clipboard!');
}).catch(() => {
this.toastService.error('Failed to copy token');
});
}
registryBaseUrl = signal('localhost:5000');
ngOnDestroy(): void {
if (this.logsRefreshInterval) {
clearInterval(this.logsRefreshInterval);
}
}
}

View File

@@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-settings',
@@ -67,6 +68,7 @@ import { ApiService } from '../../core/services/api.service';
})
export class SettingsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
settings: any = {};
ngOnInit(): void {
@@ -90,7 +92,9 @@ export class SettingsComponent implements OnInit {
);
Promise.all(promises).then(() => {
alert('Settings saved successfully');
this.toastService.success('Settings saved successfully');
}).catch((error) => {
this.toastService.error('Failed to save settings: ' + (error.message || 'Unknown error'));
});
}
}

View File

@@ -2,11 +2,12 @@ import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../core/services/auth.service';
import { ToastComponent } from './toast.component';
@Component({
selector: 'app-layout',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, ToastComponent],
template: `
<div class="min-h-screen bg-gray-50">
<!-- Navigation -->
@@ -48,11 +49,11 @@ import { AuthService } from '../../core/services/auth.service';
DNS
</a>
<a
routerLink="/ssl"
routerLink="/domains"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
SSL
Domains
</a>
<a
routerLink="/settings"
@@ -77,6 +78,9 @@ import { AuthService } from '../../core/services/auth.service';
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<router-outlet></router-outlet>
</main>
<!-- Toast Notifications -->
<app-toast></app-toast>
</div>
`,
})

View File

@@ -0,0 +1,48 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-loading-spinner',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex items-center justify-center" [class]="containerClass">
<div
class="spinner border-t-transparent rounded-full animate-spin"
[ngClass]="{
'w-4 h-4 border-2': size === 'sm',
'w-6 h-6 border-2': size === 'md',
'w-8 h-8 border-3': size === 'lg',
'w-12 h-12 border-4': size === 'xl',
'border-primary-600': color === 'primary',
'border-white': color === 'white',
'border-gray-600': color === 'gray'
}"
></div>
@if (text) {
<span class="ml-3 text-sm text-gray-600">{{ text }}</span>
}
</div>
`,
styles: [`
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.border-3 {
border-width: 3px;
}
`]
})
export class LoadingSpinnerComponent {
@Input() size: 'sm' | 'md' | 'lg' | 'xl' = 'md';
@Input() color: 'primary' | 'white' | 'gray' = 'primary';
@Input() text?: string;
@Input() containerClass?: string;
}

View File

@@ -0,0 +1,91 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
imports: [CommonModule],
template: `
<div class="fixed top-4 right-4 z-50 space-y-2">
@for (toast of toastService.toasts(); track toast.id) {
<div
class="toast-item animate-slide-in-right shadow-lg rounded-lg px-4 py-3 flex items-start gap-3 min-w-[320px] max-w-md"
[ngClass]="{
'bg-green-50 border-l-4 border-green-500 text-green-900': toast.type === 'success',
'bg-red-50 border-l-4 border-red-500 text-red-900': toast.type === 'error',
'bg-blue-50 border-l-4 border-blue-500 text-blue-900': toast.type === 'info',
'bg-yellow-50 border-l-4 border-yellow-500 text-yellow-900': toast.type === 'warning'
}"
>
<!-- Icon -->
<div class="flex-shrink-0 mt-0.5">
@if (toast.type === 'success') {
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
}
@if (toast.type === 'error') {
<svg class="w-5 h-5 text-red-500" 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"></path>
</svg>
}
@if (toast.type === 'info') {
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}
@if (toast.type === 'warning') {
<svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
</svg>
}
</div>
<!-- Message -->
<div class="flex-1 text-sm font-medium">
{{ toast.message }}
</div>
<!-- Close button -->
<button
type="button"
(click)="toastService.remove(toast.id)"
class="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-4 h-4" 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"></path>
</svg>
</button>
</div>
}
</div>
`,
styles: [`
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
.toast-item {
transition: all 0.3s ease-out;
}
.toast-item:hover {
transform: translateY(-2px);
}
`]
})
export class ToastComponent {
toastService = inject(ToastService);
}