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:
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
|
||||
53
ui/src/app/core/services/toast.service.ts
Normal file
53
ui/src/app/core/services/toast.service.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
356
ui/src/app/features/domains/domain-detail.component.ts
Normal file
356
ui/src/app/features/domains/domain-detail.component.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 →
|
||||
</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({
|
||||
|
||||
@@ -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 →
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
})
|
||||
|
||||
48
ui/src/app/shared/components/loading-spinner.component.ts
Normal file
48
ui/src/app/shared/components/loading-spinner.component.ts
Normal 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;
|
||||
}
|
||||
91
ui/src/app/shared/components/toast.component.ts
Normal file
91
ui/src/app/shared/components/toast.component.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user