ui rebuild
This commit is contained in:
@@ -1,242 +1,262 @@
|
||||
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, SystemStatus } from '../../core/services/api.service';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { ISystemStatus } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
@if (lastUpdated()) {
|
||||
<span class="text-sm text-gray-500">
|
||||
Last updated: {{ lastUpdated()!.toLocaleTimeString() }}
|
||||
</span>
|
||||
}
|
||||
<button (click)="refresh()" class="btn btn-secondary text-sm" [disabled]="loading()">
|
||||
<svg class="w-4 h-4 mr-1" [class.animate-spin]="loading()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p class="text-muted-foreground">System overview and quick actions</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="loadStatus()" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
@if (loading() && !status()) {
|
||||
<!-- Loading skeleton -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@for (_ of [1,2,3,4]; track $index) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-skeleton class="h-4 w-24" />
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
} @else if (status()) {
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<!-- Total Services -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Total Services</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ status()!.services.total }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Running Services -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Running</ui-card-title>
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-success">{{ status()!.services.running }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Stopped Services -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Stopped</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ status()!.services.stopped }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Docker Status -->
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
|
||||
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.09-.458-1.314-1.605-2.16-2.898-2.16h-.048c-.328 0-.654.06-.969.18-.618-2.066-2.215-3.073-4.752-3.073-2.538 0-4.135 1.007-4.753 3.073-.315-.12-.64-.18-.969-.18h-.048c-1.293 0-2.44.846-2.898 2.16a8.39 8.39 0 00-1.01-.09c-1.282 0-1.889.459-1.954.51L0 10.2l.08.31s.935 3.605 4.059 4.794v.003c.563.215 1.156.322 1.756.322.71 0 1.423-.129 2.112-.385a8.804 8.804 0 002.208.275c.877 0 1.692-.165 2.411-.49a4.71 4.71 0 001.617.28c.606 0 1.201-.11 1.773-.328.572.219 1.167.327 1.772.327.71 0 1.423-.129 2.112-.385.79.251 1.57.376 2.315.376.606 0 1.2-.107 1.766-.322v-.003c3.124-1.189 4.059-4.794 4.059-4.794l.08-.31-.237-.31z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
|
||||
<dd class="text-lg font-semibold text-gray-900">
|
||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Docker</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-badge [variant]="status()!.docker.running ? 'success' : 'destructive'">
|
||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||
</ui-badge>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Docker -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Docker</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Status</span>
|
||||
<span [ngClass]="status()!.docker.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (status()!.docker.version) {
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Version</span>
|
||||
<span class="text-sm text-gray-900">{{ status()!.docker.version.Version }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Reverse Proxy -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Reverse Proxy</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">HTTP (Port {{ status()!.reverseProxy.http.port }})</span>
|
||||
<span [ngClass]="status()!.reverseProxy.http.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
|
||||
</span>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Reverse Proxy</ui-card-title>
|
||||
<ui-card-description>HTTP/HTTPS proxy status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">HTTP ({{ status()!.reverseProxy.http.port }})</span>
|
||||
<ui-badge [variant]="status()!.reverseProxy.http.running ? 'success' : 'secondary'">
|
||||
{{ status()!.reverseProxy.http.running ? 'Active' : 'Inactive' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">HTTPS (Port {{ status()!.reverseProxy.https.port }})</span>
|
||||
<span [ngClass]="status()!.reverseProxy.https.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ status()!.reverseProxy.https.running ? 'Running' : 'Stopped' }}
|
||||
</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">HTTPS ({{ status()!.reverseProxy.https.port }})</span>
|
||||
<ui-badge [variant]="status()!.reverseProxy.https.running ? 'success' : 'secondary'">
|
||||
{{ status()!.reverseProxy.https.running ? 'Active' : 'Inactive' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">SSL Certificates</span>
|
||||
<span class="badge badge-info">{{ status()!.reverseProxy.https.certificates }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Certificates</span>
|
||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- DNS & SSL -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">DNS Configured</span>
|
||||
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
||||
{{ status()!.dns.configured ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
<!-- DNS -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>DNS</ui-card-title>
|
||||
<ui-card-description>DNS configuration status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Cloudflare</span>
|
||||
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">SSL Configured</span>
|
||||
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
||||
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- SSL -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>SSL/TLS</ui-card-title>
|
||||
<ui-card-description>Certificate management</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">ACME</span>
|
||||
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Certbot</span>
|
||||
<ui-badge [variant]="status()!.ssl.certbotInstalled ? 'success' : 'secondary'">
|
||||
{{ status()!.ssl.certbotInstalled ? 'Installed' : 'Not installed' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
|
||||
<div class="flex space-x-4">
|
||||
<a routerLink="/services/new" class="btn btn-primary">
|
||||
Deploy New Service
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Quick Actions</ui-card-title>
|
||||
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="flex gap-4">
|
||||
<a routerLink="/services/create">
|
||||
<button uiButton>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Deploy Service
|
||||
</button>
|
||||
</a>
|
||||
<a routerLink="/services" class="btn btn-secondary">
|
||||
View All Services
|
||||
<a routerLink="/services">
|
||||
<button uiButton variant="outline">View All Services</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a routerLink="/domains">
|
||||
<button uiButton variant="outline">Manage Domains</button>
|
||||
</a>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private wsService = inject(WebSocketService);
|
||||
private api = inject(ApiService);
|
||||
private ws = inject(WebSocketService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
status = signal<SystemStatus | null>(null);
|
||||
loading = signal(true);
|
||||
lastUpdated = signal<Date | null>(null);
|
||||
private wsSubscription?: Subscription;
|
||||
private refreshInterval?: number;
|
||||
status = signal<ISystemStatus | null>(null);
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStatus();
|
||||
private refreshInterval: any;
|
||||
|
||||
// Subscribe to WebSocket updates
|
||||
this.wsSubscription = this.wsService.getMessages().subscribe((message: any) => {
|
||||
// Reload status on any service or system update
|
||||
if (message.type === 'service_update' || message.type === 'service_status' || message.type === 'system_status') {
|
||||
constructor() {
|
||||
// React to WebSocket updates
|
||||
effect(() => {
|
||||
const update = this.ws.serviceUpdates();
|
||||
const systemStatus = this.ws.systemStatus();
|
||||
if (update || systemStatus) {
|
||||
this.loadStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStatus();
|
||||
// Auto-refresh every 30 seconds
|
||||
this.refreshInterval = window.setInterval(() => {
|
||||
this.loadStatus();
|
||||
}, 30000);
|
||||
this.refreshInterval = setInterval(() => this.loadStatus(), 30000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.wsSubscription?.unsubscribe();
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
loadStatus(): void {
|
||||
async loadStatus(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.apiService.getStatus().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.status.set(response.data);
|
||||
this.lastUpdated.set(new Date());
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadStatus();
|
||||
try {
|
||||
const response = await this.api.getStatus();
|
||||
if (response.success && response.data) {
|
||||
this.status.set(response.data);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to load status');
|
||||
}
|
||||
} catch (err) {
|
||||
this.toast.error('Failed to load status');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +1,207 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDnsRecord } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dns',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">DNS Records</h1>
|
||||
<button
|
||||
(click)="syncRecords()"
|
||||
[disabled]="syncing()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">DNS Records</h1>
|
||||
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
|
||||
</div>
|
||||
<button uiButton (click)="syncRecords()" [disabled]="syncing()">
|
||||
@if (syncing()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Syncing...
|
||||
} @else {
|
||||
Sync Cloudflare
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (records().length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (record of records(); track record.domain) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.domain }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.type }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ record.value }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button (click)="deleteRecord(record)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && records().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">No DNS records configured</p>
|
||||
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Or click "Sync Cloudflare" to import existing DNS records from Cloudflare</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (records().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold">No DNS records</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">DNS records are created automatically when you deploy services with domains.</p>
|
||||
<button uiButton class="mt-4" (click)="syncRecords()">Sync from Cloudflare</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Value</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (record of records(); track record.id) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ record.domain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="secondary">{{ record.type }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-mono text-sm">{{ record.value }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(record)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete DNS Record</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete the record for "{{ recordToDelete()?.domain }}"?
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton variant="destructive" (click)="deleteRecord()">Delete</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class DnsComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
records = signal<any[]>([]);
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
records = signal<IDnsRecord[]>([]);
|
||||
loading = signal(false);
|
||||
syncing = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
recordToDelete = signal<IDnsRecord | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRecords();
|
||||
}
|
||||
|
||||
loadRecords(): void {
|
||||
this.apiService.getDnsRecords().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.records.set(response.data);
|
||||
}
|
||||
},
|
||||
});
|
||||
async loadRecords(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getDnsRecords();
|
||||
if (response.success && response.data) {
|
||||
this.records.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load DNS records');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
syncRecords(): void {
|
||||
async syncRecords(): Promise<void> {
|
||||
this.syncing.set(true);
|
||||
this.apiService.syncDnsRecords().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success) {
|
||||
this.toastService.success('Cloudflare DNS records synced successfully');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toastService.error(response.error || 'Failed to sync DNS records');
|
||||
}
|
||||
this.syncing.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toastService.error('Failed to sync DNS records');
|
||||
this.syncing.set(false);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await this.api.syncDnsRecords();
|
||||
if (response.success) {
|
||||
this.toast.success('DNS records synced');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to sync DNS records');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to sync DNS records');
|
||||
} finally {
|
||||
this.syncing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
deleteRecord(record: any): void {
|
||||
if (confirm(`Delete DNS record for ${record.domain}?`)) {
|
||||
this.apiService.deleteDnsRecord(record.domain).subscribe({
|
||||
next: () => this.loadRecords(),
|
||||
});
|
||||
confirmDelete(record: IDnsRecord): void {
|
||||
this.recordToDelete.set(record);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteRecord(): Promise<void> {
|
||||
const record = this.recordToDelete();
|
||||
if (!record) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteDnsRecord(record.domain);
|
||||
if (response.success) {
|
||||
this.toast.success('DNS record deleted');
|
||||
this.loadRecords();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete record');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete record');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.recordToDelete.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,356 +1,289 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
|
||||
interface DomainDetail {
|
||||
domain: {
|
||||
id: number;
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
cloudflareZoneId?: string;
|
||||
isObsolete: boolean;
|
||||
defaultWildcard: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
certificates: Array<{
|
||||
id: number;
|
||||
certDomain: string;
|
||||
isWildcard: boolean;
|
||||
expiryDate: number;
|
||||
issuer: string;
|
||||
isValid: boolean;
|
||||
createdAt: number;
|
||||
}>;
|
||||
requirements: Array<{
|
||||
id: number;
|
||||
serviceId: number;
|
||||
subdomain: string;
|
||||
status: 'pending' | 'active' | 'renewing';
|
||||
certificateId?: number;
|
||||
}>;
|
||||
services: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
domain: string;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IDomainDetail, IService } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-domain-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center mb-4">
|
||||
<a
|
||||
routerLink="/domains"
|
||||
class="text-primary-600 hover:text-primary-900 mr-4"
|
||||
>
|
||||
← Back to Domains
|
||||
</a>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a routerLink="/domains" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Domains
|
||||
</a>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">Loading domain details...</p>
|
||||
</div>
|
||||
} @else if (domainDetail()) {
|
||||
<div>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
{{ domainDetail()!.domain.domain }}
|
||||
</h1>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
@if (domainDetail()!.domain.dnsProvider === 'cloudflare') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Cloudflare
|
||||
</span>
|
||||
} @else if (domainDetail()!.domain.dnsProvider === 'manual') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Manual DNS
|
||||
</span>
|
||||
}
|
||||
@if (domainDetail()!.domain.defaultWildcard) {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Wildcard Enabled
|
||||
</span>
|
||||
}
|
||||
@if (domainDetail()!.domain.isObsolete) {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Obsolete
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Certificates</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{{ domainDetail()!.certificates.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Requirements</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{{ domainDetail()!.requirements.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Services</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{{ domainDetail()!.services.length }}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Certificates Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">SSL Certificates</h2>
|
||||
@if (domainDetail()!.certificates.length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (cert of domainDetail()!.certificates; track cert.id) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ cert.certDomain }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if (cert.isWildcard) {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Wildcard
|
||||
</span>
|
||||
} @else {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Standard
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if (getCertStatus(cert) === 'valid') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Valid
|
||||
</span>
|
||||
} @else if (getCertStatus(cert) === 'expiring') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Expiring Soon
|
||||
</span>
|
||||
} @else {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Expired/Invalid
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ formatDate(cert.expiryDate) }}
|
||||
<span class="text-gray-500">({{ getDaysRemaining(cert.expiryDate) }} days)</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ cert.issuer }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-8">
|
||||
<p class="text-gray-500">No certificates for this domain</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Certificate Requirements Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Certificate Requirements</h2>
|
||||
@if (domainDetail()!.requirements.length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Subdomain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (req of domainDetail()!.requirements; track req.id) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ getServiceName(req.serviceId) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ req.subdomain || '(root)' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if (req.status === 'active') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
} @else if (req.status === 'pending') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Pending
|
||||
</span>
|
||||
} @else if (req.status === 'renewing') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Renewing
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ req.certificateId || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-8">
|
||||
<p class="text-gray-500">No certificate requirements</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Services Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Services Using This Domain</h2>
|
||||
@if (domainDetail()!.services.length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (service of domainDetail()!.services; track service.id) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ service.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ service.domain }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if (service.status === 'running') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Running
|
||||
</span>
|
||||
} @else if (service.status === 'stopped') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Stopped
|
||||
</span>
|
||||
} @else {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{{ service.status }}
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<a
|
||||
[routerLink]="['/services', service.name]"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View Service
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-8">
|
||||
<p class="text-gray-500">No services using this domain</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">Domain not found</p>
|
||||
@if (loading() && !domain()) {
|
||||
<ui-skeleton class="h-9 w-64" />
|
||||
} @else if (domain()) {
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ domain()!.domain.domain }}</h1>
|
||||
<ui-badge [variant]="domain()!.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||
{{ domain()!.domain.dnsProvider || 'Manual' }}
|
||||
</ui-badge>
|
||||
@if (domain()!.domain.defaultWildcard) {
|
||||
<ui-badge variant="outline">Wildcard</ui-badge>
|
||||
}
|
||||
@if (domain()!.domain.isObsolete) {
|
||||
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (domain()) {
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domain()!.certificates.length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Requirements</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domain()!.requirements.length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Services</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domain()!.serviceCount }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Certificates -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>SSL Certificates</ui-card-title>
|
||||
<ui-card-description>Active certificates for this domain</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (domain()!.certificates.length === 0) {
|
||||
<div class="p-6 text-center text-muted-foreground">No certificates</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head>Issuer</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (cert of domain()!.certificates; track cert.id) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ cert.certDomain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge variant="outline">{{ cert.isWildcard ? 'Wildcard' : 'Standard' }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getCertStatusVariant(cert)">
|
||||
{{ getCertStatus(cert) }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
{{ formatDate(cert.expiryDate) }}
|
||||
<span class="text-xs text-muted-foreground ml-1">
|
||||
({{ getDaysRemaining(cert.expiryDate) }} days)
|
||||
</span>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ cert.issuer }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="outline" size="sm" (click)="renewCertificate(cert.certDomain)">
|
||||
Renew
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Services using this domain -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Services</ui-card-title>
|
||||
<ui-card-description>Services using this domain</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (services().length === 0) {
|
||||
<div class="p-6 text-center text-muted-foreground">No services using this domain</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Service</ui-table-head>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (svc of services(); track svc.name) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ svc.name }}</ui-table-cell>
|
||||
<ui-table-cell>{{ svc.domain }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="svc.status === 'running' ? 'success' : 'secondary'">
|
||||
{{ svc.status }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<a [routerLink]="['/services', svc.name]">
|
||||
<button uiButton variant="outline" size="sm">View</button>
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DomainDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private apiService = inject(ApiService);
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
domainDetail = signal<DomainDetail | null>(null);
|
||||
loading = signal(true);
|
||||
domain = signal<IDomainDetail | null>(null);
|
||||
services = signal<IService[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
const domain = this.route.snapshot.paramMap.get('domain');
|
||||
if (domain) {
|
||||
this.loadDomainDetail(domain);
|
||||
const domainName = this.route.snapshot.paramMap.get('domain');
|
||||
if (domainName) {
|
||||
this.loadDomain(domainName);
|
||||
this.loadServices(domainName);
|
||||
}
|
||||
}
|
||||
|
||||
loadDomainDetail(domain: string): void {
|
||||
async loadDomain(name: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.apiService.getDomainDetail(domain).subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.domainDetail.set(response.data);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await this.api.getDomainDetail(name);
|
||||
if (response.success && response.data) {
|
||||
this.domain.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load domain');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadServices(domainName: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getServices();
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data.filter(s => s.domain?.includes(domainName)));
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
getDaysRemaining(expiryDate: number): number {
|
||||
getDaysRemaining(timestamp: number): number {
|
||||
const now = Date.now();
|
||||
const diff = expiryDate - now;
|
||||
return Math.floor(diff / (24 * 60 * 60 * 1000));
|
||||
return Math.floor((timestamp - now) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
getCertStatus(cert: any): 'valid' | 'expiring' | 'invalid' {
|
||||
if (!cert.isValid) return 'invalid';
|
||||
const daysRemaining = this.getDaysRemaining(cert.expiryDate);
|
||||
if (daysRemaining < 0) return 'invalid';
|
||||
if (daysRemaining <= 30) return 'expiring';
|
||||
return 'valid';
|
||||
getCertStatus(cert: any): string {
|
||||
if (!cert.isValid) return 'Invalid';
|
||||
const days = this.getDaysRemaining(cert.expiryDate);
|
||||
if (days < 0) return 'Expired';
|
||||
if (days <= 30) return 'Expiring';
|
||||
return 'Valid';
|
||||
}
|
||||
|
||||
getServiceName(serviceId: number): string {
|
||||
const service = this.domainDetail()?.services.find((s) => s.id === serviceId);
|
||||
return service?.name || `Service #${serviceId}`;
|
||||
getCertStatusVariant(cert: any): 'success' | 'warning' | 'destructive' {
|
||||
const status = this.getCertStatus(cert);
|
||||
switch (status) {
|
||||
case 'Valid': return 'success';
|
||||
case 'Expiring': return 'warning';
|
||||
default: return 'destructive';
|
||||
}
|
||||
}
|
||||
|
||||
async renewCertificate(domain: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.renewCertificate(domain);
|
||||
if (response.success) {
|
||||
this.toast.success('Certificate renewal initiated');
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to renew certificate');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to renew certificate');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,216 +1,242 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
interface DomainView {
|
||||
domain: {
|
||||
id: number;
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
isObsolete: boolean;
|
||||
defaultWildcard: boolean;
|
||||
};
|
||||
serviceCount: number;
|
||||
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
|
||||
daysRemaining: number | null;
|
||||
certificates: any[];
|
||||
requirements: any[];
|
||||
}
|
||||
import { IDomainDetail } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-domains',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Domains</h1>
|
||||
<button
|
||||
(click)="syncDomains()"
|
||||
[disabled]="syncing()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Domains</h1>
|
||||
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
|
||||
</div>
|
||||
<button uiButton (click)="syncDomains()" [disabled]="syncing()">
|
||||
@if (syncing()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Syncing...
|
||||
} @else {
|
||||
Sync Cloudflare
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">Loading domains...</p>
|
||||
</div>
|
||||
} @else if (domains().length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Services</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (domainView of domains(); track domainView.domain.id) {
|
||||
<tr [class.opacity-50]="domainView.domain.isObsolete">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ domainView.domain.domain }}</div>
|
||||
@if (domainView.domain.isObsolete) {
|
||||
<span class="text-xs text-red-600">Obsolete</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if (domainView.domain.dnsProvider === 'cloudflare') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Cloudflare
|
||||
</span>
|
||||
} @else if (domainView.domain.dnsProvider === 'manual') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Manual
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-sm text-gray-400">None</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ domainView.serviceCount }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@switch (domainView.certificateStatus) {
|
||||
@case ('valid') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Valid
|
||||
</span>
|
||||
}
|
||||
@case ('expiring-soon') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Expiring Soon
|
||||
</span>
|
||||
}
|
||||
@case ('expired') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Expired
|
||||
</span>
|
||||
}
|
||||
@case ('pending') {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Pending
|
||||
</span>
|
||||
}
|
||||
@default {
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
None
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
@if (domainView.daysRemaining !== null) {
|
||||
<span [ngClass]="domainView.daysRemaining <= 30 ? 'text-red-600 font-medium' : 'text-gray-500'">
|
||||
{{ domainView.daysRemaining }} days
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-gray-400">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<a
|
||||
[routerLink]="['/domains', domainView.domain.domain]"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Total Domains</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ domains().length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Valid Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-success">{{ countByStatus('valid') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Expiring Soon</ui-card-title>
|
||||
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-warning">{{ countByStatus('expiring-soon') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Expired/Pending</ui-card-title>
|
||||
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold text-destructive">{{ countByStatus('expired') + countByStatus('pending') }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-4">
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Domains</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">{{ domains().length }}</dd>
|
||||
</div>
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Valid Certificates</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-green-600">{{ getStatusCount('valid') }}</dd>
|
||||
</div>
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Expiring Soon</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-yellow-600">{{ getStatusCount('expiring-soon') }}</dd>
|
||||
</div>
|
||||
<div class="card">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Expired/Pending</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-red-600">{{ getStatusCount('expired') + getStatusCount('pending') }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-500">No domains found</p>
|
||||
<p class="text-sm text-gray-400 mt-2">
|
||||
Sync your Cloudflare zones or manually add domains to get started
|
||||
</p>
|
||||
<button
|
||||
(click)="syncDomains()"
|
||||
class="mt-4 btn btn-primary"
|
||||
>
|
||||
Sync Cloudflare Domains
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Domains Table -->
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && domains().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</div>
|
||||
} @else if (domains().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<h3 class="text-lg font-semibold">No domains found</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Sync domains from Cloudflare to get started.</p>
|
||||
<button uiButton class="mt-4" (click)="syncDomains()">Sync Cloudflare Domains</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Provider</ui-table-head>
|
||||
<ui-table-head>Services</ui-table-head>
|
||||
<ui-table-head>Certificate</ui-table-head>
|
||||
<ui-table-head>Expires</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (d of domains(); track d.domain.id) {
|
||||
<ui-table-row [class.opacity-50]="d.domain.isObsolete">
|
||||
<ui-table-cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ d.domain.domain }}</span>
|
||||
@if (d.domain.isObsolete) {
|
||||
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||
}
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="d.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||
{{ d.domain.dnsProvider || 'None' }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>{{ d.serviceCount }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getCertStatusVariant(d.certificateStatus)">
|
||||
{{ d.certificateStatus }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (d.daysRemaining !== null) {
|
||||
<span [class.text-destructive]="d.daysRemaining <= 30">
|
||||
{{ d.daysRemaining }} days
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<a [routerLink]="['/domains', d.domain.domain]">
|
||||
<button uiButton variant="outline" size="sm">View</button>
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DomainsComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
domains = signal<DomainView[]>([]);
|
||||
loading = signal(true);
|
||||
domains = signal<IDomainDetail[]>([]);
|
||||
loading = signal(false);
|
||||
syncing = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDomains();
|
||||
}
|
||||
|
||||
loadDomains(): void {
|
||||
async loadDomains(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.apiService.getDomains().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.domains.set(response.data);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await this.api.getDomains();
|
||||
if (response.success && response.data) {
|
||||
this.domains.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load domains');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
syncDomains(): void {
|
||||
async syncDomains(): Promise<void> {
|
||||
this.syncing.set(true);
|
||||
this.apiService.syncCloudflareDomains().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success) {
|
||||
this.toastService.success('Cloudflare domains synced successfully');
|
||||
this.loadDomains();
|
||||
}
|
||||
this.syncing.set(false);
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.error('Failed to sync Cloudflare domains: ' + (error.error?.error || error.message));
|
||||
this.syncing.set(false);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await this.api.syncCloudflareDomains();
|
||||
if (response.success) {
|
||||
this.toast.success('Domains synced');
|
||||
this.loadDomains();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to sync domains');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to sync domains');
|
||||
} finally {
|
||||
this.syncing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getStatusCount(status: string): number {
|
||||
countByStatus(status: string): number {
|
||||
return this.domains().filter(d => d.certificateStatus === status).length;
|
||||
}
|
||||
|
||||
getCertStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'valid': return 'success';
|
||||
case 'expiring-soon': return 'warning';
|
||||
case 'expired': return 'destructive';
|
||||
case 'pending': return 'secondary';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +1,163 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
AlertComponent,
|
||||
AlertDescriptionComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Onebox
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" (ngSubmit)="onSubmit()">
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="username" class="sr-only">Username</label>
|
||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div class="absolute top-4 right-4">
|
||||
<button
|
||||
uiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
(click)="theme.toggle()"
|
||||
>
|
||||
@if (theme.resolvedTheme() === 'dark') {
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-card class="w-full max-w-md">
|
||||
<ui-card-header class="text-center">
|
||||
<div class="mx-auto mb-4">
|
||||
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<ui-card-title>Welcome to Onebox</ui-card-title>
|
||||
<ui-card-description>Enter your credentials to sign in</ui-card-description>
|
||||
</ui-card-header>
|
||||
|
||||
<form (ngSubmit)="onSubmit()">
|
||||
<ui-card-content class="space-y-4">
|
||||
@if (error()) {
|
||||
<ui-alert variant="destructive">
|
||||
<ui-alert-description>{{ error() }}</ui-alert-description>
|
||||
</ui-alert>
|
||||
}
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="username">Username</label>
|
||||
<input
|
||||
uiInput
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
[(ngModel)]="username"
|
||||
name="username"
|
||||
placeholder="Enter username"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="password">Password</label>
|
||||
<input
|
||||
uiInput
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
|
||||
@if (error) {
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{{ error }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<ui-card-footer>
|
||||
<button
|
||||
uiButton
|
||||
type="submit"
|
||||
[disabled]="loading"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
class="w-full"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 text-center">
|
||||
<p>Default credentials: admin / admin</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
|
||||
</div>
|
||||
</ui-card-footer>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-6">
|
||||
<p class="text-xs text-center text-muted-foreground">
|
||||
Default credentials: admin / admin
|
||||
</p>
|
||||
</div>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
theme = inject(ThemeService);
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
onSubmit(): void {
|
||||
this.error = '';
|
||||
this.loading = true;
|
||||
async onSubmit(): Promise<void> {
|
||||
if (!this.username || !this.password) {
|
||||
this.error.set('Please enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.authService.login({ username: this.username, password: this.password }).subscribe({
|
||||
next: (response) => {
|
||||
this.loading = false;
|
||||
if (response.success) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.error = response.error || 'Login failed';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.error = err.error?.error || 'An error occurred during login';
|
||||
},
|
||||
});
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const result = await this.auth.login(this.username, this.password);
|
||||
|
||||
this.loading.set(false);
|
||||
|
||||
if (result.success) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.error.set(result.error || 'Invalid credentials');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +1,229 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService, Registry } from '../../core/services/api.service';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registries',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
|
||||
|
||||
<!-- Add Registry Form -->
|
||||
<div class="card mb-8 max-w-2xl">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
|
||||
<form (ngSubmit)="addRegistry()" class="space-y-4">
|
||||
<div>
|
||||
<label for="url" class="label">Registry URL</label>
|
||||
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="label">Username</label>
|
||||
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="label">Password</label>
|
||||
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Registry</button>
|
||||
</form>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Docker Registries</h1>
|
||||
<p class="text-muted-foreground">Manage Docker registry credentials</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Registry Form -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Add Registry</ui-card-title>
|
||||
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<form (ngSubmit)="addRegistry()" class="grid gap-4 md:grid-cols-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Registry URL</label>
|
||||
<input uiInput [(ngModel)]="form.url" name="url" placeholder="registry.example.com" required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Username</label>
|
||||
<input uiInput [(ngModel)]="form.username" name="username" required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="form.password" name="password" required />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button uiButton type="submit" [disabled]="loading()">Add Registry</button>
|
||||
</div>
|
||||
</form>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Registries List -->
|
||||
@if (registries().length > 0) {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (registry of registries(); track registry.id) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Registered Registries</ui-card-title>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && registries().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (registries().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-muted-foreground">No registries configured</p>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>URL</ui-table-head>
|
||||
<ui-table-head>Username</ui-table-head>
|
||||
<ui-table-head>Created</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (registry of registries(); track registry.id) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell class="font-medium">{{ registry.url }}</ui-table-cell>
|
||||
<ui-table-cell>{{ registry.username }}</ui-table-cell>
|
||||
<ui-table-cell>{{ formatDate(registry.createdAt) }}</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(registry)">
|
||||
Delete
|
||||
</button>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete Registry</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete "{{ registryToDelete()?.url }}"?
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton variant="destructive" (click)="deleteRegistry()">Delete</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class RegistriesComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
registries = signal<Registry[]>([]);
|
||||
newRegistry = { url: '', username: '', password: '' };
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
registries = signal<IRegistry[]>([]);
|
||||
loading = signal(false);
|
||||
deleteDialogOpen = signal(false);
|
||||
registryToDelete = signal<IRegistry | null>(null);
|
||||
|
||||
form: IRegistryCreate = { url: '', username: '', password: '' };
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRegistries();
|
||||
}
|
||||
|
||||
loadRegistries(): void {
|
||||
this.apiService.getRegistries().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.registries.set(response.data);
|
||||
}
|
||||
},
|
||||
});
|
||||
async loadRegistries(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getRegistries();
|
||||
if (response.success && response.data) {
|
||||
this.registries.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load registries');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
addRegistry(): void {
|
||||
this.apiService.createRegistry(this.newRegistry).subscribe({
|
||||
next: () => {
|
||||
this.newRegistry = { url: '', username: '', password: '' };
|
||||
async addRegistry(): Promise<void> {
|
||||
if (!this.form.url || !this.form.username || !this.form.password) {
|
||||
this.toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.createRegistry(this.form);
|
||||
if (response.success) {
|
||||
this.toast.success('Registry added');
|
||||
this.form = { url: '', username: '', password: '' };
|
||||
this.loadRegistries();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to add registry');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to add registry');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
deleteRegistry(registry: Registry): void {
|
||||
if (confirm(`Delete registry ${registry.url}?`)) {
|
||||
this.apiService.deleteRegistry(registry.url).subscribe({
|
||||
next: () => this.loadRegistries(),
|
||||
});
|
||||
confirmDelete(registry: IRegistry): void {
|
||||
this.registryToDelete.set(registry);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteRegistry(): Promise<void> {
|
||||
const registry = this.registryToDelete();
|
||||
if (!registry?.id) return;
|
||||
|
||||
try {
|
||||
const response = await this.api.deleteRegistry(registry.id);
|
||||
if (response.success) {
|
||||
this.toast.success('Registry deleted');
|
||||
this.loadRegistries();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete registry');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete registry');
|
||||
} finally {
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.registryToDelete.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,354 +1,317 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IServiceCreate, IDomainDetail } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { CheckboxComponent } from '../../ui/checkbox/checkbox.component';
|
||||
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
|
||||
import { SeparatorComponent } from '../../ui/separator/separator.component';
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
isObsolete: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
CardFooterComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
CheckboxComponent,
|
||||
AlertComponent,
|
||||
AlertDescriptionComponent,
|
||||
SeparatorComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Services
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Deploy Service</h1>
|
||||
<p class="text-muted-foreground">Deploy a new Docker service</p>
|
||||
</div>
|
||||
|
||||
<div class="card max-w-3xl">
|
||||
<form (ngSubmit)="onSubmit()">
|
||||
<!-- Name -->
|
||||
<div class="mb-6">
|
||||
<label for="name" class="label">Service Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="myapp"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="mb-6">
|
||||
<label for="image" class="label">Docker Image *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="image"
|
||||
[(ngModel)]="image"
|
||||
name="image"
|
||||
[required]="!useOneboxRegistry"
|
||||
[disabled]="useOneboxRegistry"
|
||||
placeholder="nginx:latest"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
|
||||
</div>
|
||||
|
||||
<!-- Onebox Registry Option -->
|
||||
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useOneboxRegistry"
|
||||
[(ngModel)]="useOneboxRegistry"
|
||||
name="useOneboxRegistry"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="useOneboxRegistry" class="ml-2 block text-sm font-medium text-gray-900">
|
||||
Use Onebox Registry
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Store your container image in the local Onebox registry instead of using an external image.
|
||||
</p>
|
||||
|
||||
@if (useOneboxRegistry) {
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="registryImageTag" class="label text-sm">Image Tag</label>
|
||||
<input
|
||||
type="text"
|
||||
id="registryImageTag"
|
||||
[(ngModel)]="registryImageTag"
|
||||
name="registryImageTag"
|
||||
placeholder="latest"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Tag to use (e.g., latest, v1.0, develop)</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoUpdateOnPush"
|
||||
[(ngModel)]="autoUpdateOnPush"
|
||||
name="autoUpdateOnPush"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="autoUpdateOnPush" class="ml-2 block text-sm text-gray-700">
|
||||
Auto-restart on new image push
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 ml-6">
|
||||
Automatically pull and restart the service when a new image is pushed to the registry
|
||||
</p>
|
||||
<form (ngSubmit)="onSubmit()">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Service Configuration</ui-card-title>
|
||||
<ui-card-description>Configure your service settings</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-6">
|
||||
<!-- Basic Configuration -->
|
||||
<div class="grid gap-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="name">Service Name</label>
|
||||
<input
|
||||
uiInput
|
||||
id="name"
|
||||
[(ngModel)]="form.name"
|
||||
name="name"
|
||||
placeholder="my-service"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Port -->
|
||||
<div class="mb-6">
|
||||
<label for="port" class="label">Container Port *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="port"
|
||||
[(ngModel)]="port"
|
||||
name="port"
|
||||
required
|
||||
placeholder="80"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
|
||||
</div>
|
||||
|
||||
<!-- Domain -->
|
||||
<div class="mb-6">
|
||||
<label for="domain" class="label">Domain (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="domain"
|
||||
[(ngModel)]="domain"
|
||||
(ngModelChange)="onDomainChange()"
|
||||
name="domain"
|
||||
placeholder="app.example.com"
|
||||
list="domainList"
|
||||
class="input"
|
||||
[class.border-red-300]="domainWarning()"
|
||||
/>
|
||||
<datalist id="domainList">
|
||||
@for (domain of availableDomains(); track domain.domain) {
|
||||
<option [value]="domain.domain">{{ domain.domain }}</option>
|
||||
}
|
||||
</datalist>
|
||||
|
||||
@if (domainWarning()) {
|
||||
<div class="mt-2 rounded-md bg-yellow-50 p-3">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{{ domainWarningTitle() }}</strong>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-yellow-700">{{ domainWarningMessage() }}</p>
|
||||
<div class="mt-2">
|
||||
<a routerLink="/domains" class="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline">
|
||||
View domains →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="image">Docker Image</label>
|
||||
<input
|
||||
uiInput
|
||||
id="image"
|
||||
[(ngModel)]="form.image"
|
||||
name="image"
|
||||
placeholder="nginx:latest"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">e.g., nginx:latest, registry.example.com/image:tag</p>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Leave empty to skip automatic DNS & SSL.
|
||||
@if (availableDomains().length > 0) {
|
||||
<span>Or select from {{ availableDomains().length }} available domain(s).</span>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="port">Container Port</label>
|
||||
<input
|
||||
uiInput
|
||||
id="port"
|
||||
type="number"
|
||||
[(ngModel)]="form.port"
|
||||
name="port"
|
||||
placeholder="80"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="domain">Domain (optional)</label>
|
||||
<input
|
||||
uiInput
|
||||
id="domain"
|
||||
[(ngModel)]="form.domain"
|
||||
name="domain"
|
||||
placeholder="app.example.com"
|
||||
list="domains-list"
|
||||
/>
|
||||
<datalist id="domains-list">
|
||||
@for (d of domains(); track d.domain.domain) {
|
||||
<option [value]="d.domain.domain">{{ d.domain.domain }}</option>
|
||||
}
|
||||
</datalist>
|
||||
@if (domainWarning()) {
|
||||
<ui-alert variant="warning" class="mt-2">
|
||||
<ui-alert-description>{{ domainWarning() }}</ui-alert-description>
|
||||
</ui-alert>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Variables -->
|
||||
<div class="mb-6">
|
||||
<label class="label">Environment Variables</label>
|
||||
@for (env of envVars(); track $index) {
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="env.key"
|
||||
[name]="'envKey' + $index"
|
||||
placeholder="KEY"
|
||||
class="input flex-1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="env.value"
|
||||
[name]="'envValue' + $index"
|
||||
placeholder="value"
|
||||
class="input flex-1"
|
||||
/>
|
||||
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
|
||||
Remove
|
||||
<ui-separator />
|
||||
|
||||
<!-- Environment Variables -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">Environment Variables</h3>
|
||||
<p class="text-xs text-muted-foreground">Configure environment variables for your service</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" size="sm" type="button" (click)="addEnvVar()">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
|
||||
Add Environment Variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoDNS"
|
||||
[(ngModel)]="autoDNS"
|
||||
name="autoDNS"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="autoDNS" class="ml-2 block text-sm text-gray-900">
|
||||
Configure DNS automatically
|
||||
</label>
|
||||
@if (envVars().length > 0) {
|
||||
<div class="space-y-2">
|
||||
@for (env of envVars(); track $index; let i = $index) {
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
uiInput
|
||||
[(ngModel)]="env.key"
|
||||
[name]="'env-key-' + i"
|
||||
placeholder="KEY"
|
||||
class="flex-1"
|
||||
/>
|
||||
<input
|
||||
uiInput
|
||||
[(ngModel)]="env.value"
|
||||
[name]="'env-value-' + i"
|
||||
placeholder="value"
|
||||
class="flex-1"
|
||||
/>
|
||||
<button
|
||||
uiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
(click)="removeEnvVar(i)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoSSL"
|
||||
[(ngModel)]="autoSSL"
|
||||
name="autoSSL"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="autoSSL" class="ml-2 block text-sm text-gray-900">
|
||||
Obtain SSL certificate automatically
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="rounded-md bg-red-50 p-4 mb-6">
|
||||
<p class="text-sm text-red-800">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
<ui-separator />
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button type="button" (click)="cancel()" class="btn btn-secondary">
|
||||
Cancel
|
||||
<!-- Onebox Registry -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.useOneboxRegistry ?? false"
|
||||
(checkedChange)="form.useOneboxRegistry = $event"
|
||||
/>
|
||||
<div>
|
||||
<label uiLabel class="cursor-pointer">Use Onebox Registry</label>
|
||||
<p class="text-xs text-muted-foreground">Push images directly to this Onebox instance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form.useOneboxRegistry) {
|
||||
<div class="pl-7 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel for="registryImageTag">Image Tag</label>
|
||||
<input
|
||||
uiInput
|
||||
id="registryImageTag"
|
||||
[(ngModel)]="form.registryImageTag"
|
||||
name="registryImageTag"
|
||||
placeholder="latest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<ui-checkbox
|
||||
[checked]="form.autoUpdateOnPush ?? false"
|
||||
(checkedChange)="form.autoUpdateOnPush = $event"
|
||||
/>
|
||||
<label uiLabel class="cursor-pointer">Auto-restart on push</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ui-card-content>
|
||||
<ui-card-footer class="flex justify-between">
|
||||
<a routerLink="/services">
|
||||
<button uiButton variant="outline" type="button">Cancel</button>
|
||||
</a>
|
||||
<button uiButton type="submit" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Deploying...
|
||||
} @else {
|
||||
Deploy Service
|
||||
}
|
||||
</button>
|
||||
<button type="submit" [disabled]="loading()" class="btn btn-primary">
|
||||
{{ loading() ? 'Deploying...' : 'Deploy Service' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ui-card-footer>
|
||||
</ui-card>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServiceCreateComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private api = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
form: IServiceCreate = {
|
||||
name: '',
|
||||
image: '',
|
||||
port: 80,
|
||||
domain: '',
|
||||
useOneboxRegistry: false,
|
||||
registryImageTag: 'latest',
|
||||
autoUpdateOnPush: false,
|
||||
};
|
||||
|
||||
name = '';
|
||||
image = '';
|
||||
port = 80;
|
||||
domain = '';
|
||||
autoDNS = true;
|
||||
autoSSL = true;
|
||||
envVars = signal<EnvVar[]>([]);
|
||||
domains = signal<IDomainDetail[]>([]);
|
||||
loading = signal(false);
|
||||
error = signal('');
|
||||
|
||||
// Onebox Registry
|
||||
useOneboxRegistry = false;
|
||||
registryImageTag = 'latest';
|
||||
autoUpdateOnPush = false;
|
||||
|
||||
// Domain validation
|
||||
availableDomains = signal<Domain[]>([]);
|
||||
domainWarning = signal(false);
|
||||
domainWarningTitle = signal('');
|
||||
domainWarningMessage = signal('');
|
||||
domainWarning = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDomains();
|
||||
}
|
||||
|
||||
loadDomains(): void {
|
||||
this.apiService.getDomains().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
const domains: Domain[] = response.data.map((d: any) => ({
|
||||
domain: d.domain.domain,
|
||||
dnsProvider: d.domain.dnsProvider,
|
||||
isObsolete: d.domain.isObsolete,
|
||||
}));
|
||||
this.availableDomains.set(domains);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Silently fail - domains list not critical
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onDomainChange(): void {
|
||||
if (!this.domain) {
|
||||
this.domainWarning.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract base domain from entered domain
|
||||
const parts = this.domain.split('.');
|
||||
if (parts.length < 2) {
|
||||
// Not a valid domain format
|
||||
this.domainWarning.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDomain = parts.slice(-2).join('.');
|
||||
|
||||
// Check if base domain exists in available domains
|
||||
const matchingDomain = this.availableDomains().find(
|
||||
(d) => d.domain === baseDomain
|
||||
);
|
||||
|
||||
if (!matchingDomain) {
|
||||
this.domainWarning.set(true);
|
||||
this.domainWarningTitle.set('Domain not found');
|
||||
this.domainWarningMessage.set(
|
||||
`The base domain "${baseDomain}" is not in the Domain table. The service will deploy, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.`
|
||||
);
|
||||
} else if (matchingDomain.isObsolete) {
|
||||
this.domainWarning.set(true);
|
||||
this.domainWarningTitle.set('Domain is obsolete');
|
||||
this.domainWarningMessage.set(
|
||||
`The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.`
|
||||
);
|
||||
} else {
|
||||
this.domainWarning.set(false);
|
||||
async loadDomains(): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.getDomains();
|
||||
if (response.success && response.data) {
|
||||
this.domains.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - domain autocomplete is optional
|
||||
}
|
||||
}
|
||||
|
||||
addEnvVar(): void {
|
||||
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
|
||||
this.envVars.update(vars => [...vars, { key: '', value: '' }]);
|
||||
}
|
||||
|
||||
removeEnvVar(index: number): void {
|
||||
this.envVars.update((vars) => vars.filter((_, i) => i !== index));
|
||||
this.envVars.update(vars => vars.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
this.error.set('');
|
||||
validateDomain(): void {
|
||||
if (!this.form.domain) {
|
||||
this.domainWarning.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = this.domains().find(d => d.domain.domain === this.form.domain);
|
||||
if (!domain) {
|
||||
this.domainWarning.set('This domain is not in your domain list. DNS and SSL may not be configured automatically.');
|
||||
} else if (domain.domain.isObsolete) {
|
||||
this.domainWarning.set('This domain is marked as obsolete.');
|
||||
} else {
|
||||
this.domainWarning.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
if (!this.form.name || !this.form.image || !this.form.port) {
|
||||
this.toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
|
||||
// Convert env vars to object
|
||||
// Build env vars object
|
||||
const envVarsObj: Record<string, string> = {};
|
||||
for (const env of this.envVars()) {
|
||||
if (env.key && env.value) {
|
||||
@@ -356,36 +319,23 @@ export class ServiceCreateComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: this.name,
|
||||
image: this.image,
|
||||
port: this.port,
|
||||
domain: this.domain || undefined,
|
||||
envVars: envVarsObj,
|
||||
autoDNS: this.autoDNS,
|
||||
autoSSL: this.autoSSL,
|
||||
useOneboxRegistry: this.useOneboxRegistry,
|
||||
registryImageTag: this.useOneboxRegistry ? this.registryImageTag : undefined,
|
||||
autoUpdateOnPush: this.useOneboxRegistry ? this.autoUpdateOnPush : undefined,
|
||||
const data: IServiceCreate = {
|
||||
...this.form,
|
||||
envVars: Object.keys(envVarsObj).length > 0 ? envVarsObj : undefined,
|
||||
};
|
||||
|
||||
this.apiService.createService(data).subscribe({
|
||||
next: (response) => {
|
||||
this.loading.set(false);
|
||||
if (response.success) {
|
||||
this.router.navigate(['/services']);
|
||||
} else {
|
||||
this.error.set(response.error || 'Failed to deploy service');
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(err.error?.error || 'An error occurred');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/services']);
|
||||
try {
|
||||
const response = await this.api.createService(data);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${this.form.name}" deployed successfully`);
|
||||
this.router.navigate(['/services']);
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to deploy service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to deploy service');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,180 +1,332 @@
|
||||
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal, effect, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, Service } from '../../core/services/api.service';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IService } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
} from '../../ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
SkeletonComponent,
|
||||
DialogComponent,
|
||||
DialogHeaderComponent,
|
||||
DialogTitleComponent,
|
||||
DialogDescriptionComponent,
|
||||
DialogFooterComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<div class="sm:flex sm:items-center sm:justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
|
||||
<div class="mt-4 sm:mt-0">
|
||||
<a routerLink="/services/new" class="btn btn-primary">
|
||||
Deploy New Service
|
||||
</a>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Services</h1>
|
||||
<p class="text-muted-foreground">Manage your deployed services</p>
|
||||
</div>
|
||||
<a routerLink="/services/create">
|
||||
<button uiButton>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Deploy Service
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
} @else if (services().length === 0) {
|
||||
<div class="card text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
|
||||
<div class="mt-6">
|
||||
<a routerLink="/services/new" class="btn btn-primary">
|
||||
Deploy Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@for (service of services(); track service.id) {
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
|
||||
{{ service.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ service.image }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ service.domain || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span [ngClass]="{
|
||||
'badge-success': service.status === 'running',
|
||||
'badge-danger': service.status === 'stopped' || service.status === 'failed',
|
||||
'badge-warning': service.status === 'starting' || service.status === 'stopping'
|
||||
}" class="badge">
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
@if (service.status === 'stopped') {
|
||||
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
|
||||
}
|
||||
@if (service.status === 'running') {
|
||||
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
|
||||
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</button>
|
||||
}
|
||||
<button (click)="deleteService(service)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Services Table -->
|
||||
<ui-card>
|
||||
<ui-card-content class="p-0">
|
||||
@if (loading() && services().length === 0) {
|
||||
<div class="p-6 space-y-4">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-skeleton class="h-12 w-full" />
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (services().length === 0) {
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold">No services</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
|
||||
<a routerLink="/services/create" class="mt-4 inline-block">
|
||||
<button uiButton>Deploy Service</button>
|
||||
</a>
|
||||
</div>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Image</ui-table-head>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (service of services(); track service.name) {
|
||||
<ui-table-row>
|
||||
<ui-table-cell>
|
||||
<a [routerLink]="['/services', service.name]" class="font-medium hover:underline">
|
||||
{{ service.name }}
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-muted-foreground">{{ service.image }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (service.domain) {
|
||||
<a [href]="'https://' + service.domain" target="_blank" class="text-primary hover:underline">
|
||||
{{ service.domain }}
|
||||
</a>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getStatusVariant(service.status)">
|
||||
{{ service.status }}
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
@if (service.status === 'stopped' || service.status === 'failed') {
|
||||
<button
|
||||
uiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
(click)="startService(service.name)"
|
||||
[disabled]="actionLoading() === service.name"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
}
|
||||
@if (service.status === 'running') {
|
||||
<button
|
||||
uiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
(click)="stopService(service.name)"
|
||||
[disabled]="actionLoading() === service.name"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
uiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
(click)="restartService(service.name)"
|
||||
[disabled]="actionLoading() === service.name"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
uiButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
(click)="confirmDelete(service)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||
<ui-dialog-header>
|
||||
<ui-dialog-title>Delete Service</ui-dialog-title>
|
||||
<ui-dialog-description>
|
||||
Are you sure you want to delete "{{ serviceToDelete()?.name }}"? This action cannot be undone.
|
||||
</ui-dialog-description>
|
||||
</ui-dialog-header>
|
||||
<ui-dialog-footer>
|
||||
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||
<button uiButton variant="destructive" (click)="deleteService()" [disabled]="!!actionLoading()">
|
||||
Delete
|
||||
</button>
|
||||
</ui-dialog-footer>
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class ServicesListComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private wsService = inject(WebSocketService);
|
||||
private wsSubscription?: Subscription;
|
||||
export class ServicesListComponent implements OnInit {
|
||||
private api = inject(ApiService);
|
||||
private ws = inject(WebSocketService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
services = signal<Service[]>([]);
|
||||
loading = signal(true);
|
||||
services = signal<IService[]>([]);
|
||||
loading = signal(false);
|
||||
actionLoading = signal<string | null>(null);
|
||||
deleteDialogOpen = signal(false);
|
||||
serviceToDelete = signal<IService | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initial load
|
||||
this.loadServices();
|
||||
|
||||
// Subscribe to WebSocket updates
|
||||
this.wsSubscription = this.wsService.getMessages().subscribe((message) => {
|
||||
this.handleWebSocketMessage(message);
|
||||
constructor() {
|
||||
// React to WebSocket updates
|
||||
effect(() => {
|
||||
const update = this.ws.serviceUpdates();
|
||||
const status = this.ws.serviceStatus();
|
||||
if (update || status) {
|
||||
this.loadServices();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.wsSubscription?.unsubscribe();
|
||||
ngOnInit(): void {
|
||||
this.loadServices();
|
||||
}
|
||||
|
||||
private handleWebSocketMessage(message: any): void {
|
||||
if (message.type === 'service_update') {
|
||||
// Reload the full service list on any service update
|
||||
this.loadServices();
|
||||
} else if (message.type === 'service_status') {
|
||||
// Update individual service status
|
||||
const currentServices = this.services();
|
||||
const updatedServices = currentServices.map(s =>
|
||||
s.name === message.serviceName
|
||||
? { ...s, status: message.status }
|
||||
: s
|
||||
);
|
||||
this.services.set(updatedServices);
|
||||
async loadServices(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getServices();
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data);
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load services');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadServices(): void {
|
||||
this.loading.set(true);
|
||||
this.apiService.getServices().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.services.set(response.data);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'success';
|
||||
case 'stopped':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
startService(service: Service): void {
|
||||
this.apiService.startService(service.name).subscribe({
|
||||
next: () => {
|
||||
// WebSocket will handle the update
|
||||
},
|
||||
});
|
||||
async startService(name: string): Promise<void> {
|
||||
this.actionLoading.set(name);
|
||||
try {
|
||||
const response = await this.api.startService(name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${name}" started`);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to start service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to start service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
stopService(service: Service): void {
|
||||
this.apiService.stopService(service.name).subscribe({
|
||||
next: () => {
|
||||
// WebSocket will handle the update
|
||||
},
|
||||
});
|
||||
async stopService(name: string): Promise<void> {
|
||||
this.actionLoading.set(name);
|
||||
try {
|
||||
const response = await this.api.stopService(name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${name}" stopped`);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to stop service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to stop service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
restartService(service: Service): void {
|
||||
this.apiService.restartService(service.name).subscribe({
|
||||
next: () => {
|
||||
// WebSocket will handle the update
|
||||
},
|
||||
});
|
||||
async restartService(name: string): Promise<void> {
|
||||
this.actionLoading.set(name);
|
||||
try {
|
||||
const response = await this.api.restartService(name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${name}" restarted`);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to restart service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to restart service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
deleteService(service: Service): void {
|
||||
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
|
||||
this.apiService.deleteService(service.name).subscribe({
|
||||
next: () => {
|
||||
// WebSocket will handle the update
|
||||
},
|
||||
});
|
||||
confirmDelete(service: IService): void {
|
||||
this.serviceToDelete.set(service);
|
||||
this.deleteDialogOpen.set(true);
|
||||
}
|
||||
|
||||
async deleteService(): Promise<void> {
|
||||
const service = this.serviceToDelete();
|
||||
if (!service) return;
|
||||
|
||||
this.actionLoading.set(service.name);
|
||||
try {
|
||||
const response = await this.api.deleteService(service.name);
|
||||
if (response.success) {
|
||||
this.toast.success(`Service "${service.name}" deleted`);
|
||||
this.deleteDialogOpen.set(false);
|
||||
this.loadServices();
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to delete service');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to delete service');
|
||||
} finally {
|
||||
this.actionLoading.set(null);
|
||||
this.serviceToDelete.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +1,337 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ISettings } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { InputComponent } from '../../ui/input/input.component';
|
||||
import { LabelComponent } from '../../ui/label/label.component';
|
||||
import { SwitchComponent } from '../../ui/switch/switch.component';
|
||||
import { SeparatorComponent } from '../../ui/separator/separator.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
LabelComponent,
|
||||
SwitchComponent,
|
||||
SeparatorComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="px-4 sm:px-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p class="text-muted-foreground">Manage system configuration</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Cloudflare Settings -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">API Key</label>
|
||||
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Zone ID</label>
|
||||
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
@if (loading()) {
|
||||
<div class="space-y-6">
|
||||
@for (_ of [1,2,3]; track $index) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-skeleton class="h-6 w-48" />
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-skeleton class="h-20 w-full" />
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Appearance -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Appearance</ui-card-title>
|
||||
<ui-card-description>Customize the look and feel</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<label uiLabel>Dark Mode</label>
|
||||
<p class="text-sm text-muted-foreground">Toggle dark mode on or off</p>
|
||||
</div>
|
||||
<ui-switch [ngModel]="theme.isDark()" (ngModelChange)="theme.toggle()" />
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Server Settings -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Server</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Server IP</label>
|
||||
<input type="text" [(ngModel)]="settings.serverIP" class="input" placeholder="1.2.3.4" />
|
||||
<!-- Cloudflare Integration -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Cloudflare Integration</ui-card-title>
|
||||
<ui-card-description>Configure Cloudflare API for DNS management</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>API Token</label>
|
||||
<input
|
||||
uiInput
|
||||
type="password"
|
||||
[(ngModel)]="settings.cloudflareToken"
|
||||
placeholder="Enter Cloudflare API token"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Zone ID (Optional)</label>
|
||||
<input
|
||||
uiInput
|
||||
[(ngModel)]="settings.cloudflareZoneId"
|
||||
placeholder="Default zone ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">HTTP Port</label>
|
||||
<input type="number" [(ngModel)]="settings.httpPort" class="input" placeholder="3000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Get your API token from the Cloudflare dashboard with DNS edit permissions.
|
||||
</p>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- SSL Settings -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">SSL / ACME</h2>
|
||||
<div>
|
||||
<label class="label">ACME Email</label>
|
||||
<input type="email" [(ngModel)]="settings.acmeEmail" class="input" placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- SSL/TLS Settings -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>SSL/TLS Settings</ui-card-title>
|
||||
<ui-card-description>Configure certificate management</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<label uiLabel>Auto-Renew Certificates</label>
|
||||
<p class="text-sm text-muted-foreground">Automatically renew certificates before expiry</p>
|
||||
</div>
|
||||
<ui-switch [(ngModel)]="settings.autoRenewCerts" />
|
||||
</div>
|
||||
<ui-separator />
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Renewal Threshold (days)</label>
|
||||
<input
|
||||
uiInput
|
||||
type="number"
|
||||
[(ngModel)]="settings.renewalThreshold"
|
||||
min="1"
|
||||
max="90"
|
||||
class="w-32"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Renew certificates when they have fewer than this many days remaining.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>ACME Email</label>
|
||||
<input
|
||||
uiInput
|
||||
type="email"
|
||||
[(ngModel)]="settings.acmeEmail"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Email address for Let's Encrypt notifications.
|
||||
</p>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Network Settings -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Network Settings</ui-card-title>
|
||||
<ui-card-description>Configure network and proxy settings</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>HTTP Port</label>
|
||||
<input
|
||||
uiInput
|
||||
type="number"
|
||||
[(ngModel)]="settings.httpPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>HTTPS Port</label>
|
||||
<input
|
||||
uiInput
|
||||
type="number"
|
||||
[(ngModel)]="settings.httpsPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<label uiLabel>Force HTTPS</label>
|
||||
<p class="text-sm text-muted-foreground">Redirect all HTTP traffic to HTTPS</p>
|
||||
</div>
|
||||
<ui-switch [(ngModel)]="settings.forceHttps" />
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Account Settings -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Account</ui-card-title>
|
||||
<ui-card-description>Manage your account settings</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Current User</label>
|
||||
<p class="text-sm font-medium">{{ auth.currentUser()?.username || 'Unknown' }}</p>
|
||||
</div>
|
||||
<ui-separator />
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium">Change Password</h4>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Current Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="passwordForm.current" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>New Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="passwordForm.new" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label uiLabel>Confirm Password</label>
|
||||
<input uiInput type="password" [(ngModel)]="passwordForm.confirm" />
|
||||
</div>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="changePassword()">
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button (click)="saveSettings()" class="btn btn-primary">
|
||||
Save Settings
|
||||
<div class="flex justify-end gap-4">
|
||||
<button uiButton variant="outline" (click)="loadSettings()">Reset</button>
|
||||
<button uiButton (click)="saveSettings()" [disabled]="saving()">
|
||||
@if (saving()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
} @else {
|
||||
Save Settings
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
settings: any = {};
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
protected theme = inject(ThemeService);
|
||||
protected auth = inject(AuthService);
|
||||
|
||||
loading = signal(false);
|
||||
saving = signal(false);
|
||||
|
||||
settings: ISettings = {
|
||||
cloudflareToken: '',
|
||||
cloudflareZoneId: '',
|
||||
autoRenewCerts: true,
|
||||
renewalThreshold: 30,
|
||||
acmeEmail: '',
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
forceHttps: true,
|
||||
};
|
||||
|
||||
passwordForm = {
|
||||
current: '',
|
||||
new: '',
|
||||
confirm: '',
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
loadSettings(): void {
|
||||
this.apiService.getSettings().subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data) {
|
||||
this.settings = response.data;
|
||||
}
|
||||
},
|
||||
});
|
||||
async loadSettings(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getSettings();
|
||||
if (response.success && response.data) {
|
||||
this.settings = { ...this.settings, ...response.data };
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to load settings');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
// Save each setting individually
|
||||
const promises = Object.entries(this.settings).map(([key, value]) =>
|
||||
this.apiService.updateSetting(key, value as string).toPromise()
|
||||
);
|
||||
async saveSettings(): Promise<void> {
|
||||
this.saving.set(true);
|
||||
try {
|
||||
const response = await this.api.updateSettings(this.settings);
|
||||
if (response.success) {
|
||||
this.toast.success('Settings saved');
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to save settings');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to save settings');
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
this.toastService.success('Settings saved successfully');
|
||||
}).catch((error) => {
|
||||
this.toastService.error('Failed to save settings: ' + (error.message || 'Unknown error'));
|
||||
});
|
||||
async changePassword(): Promise<void> {
|
||||
if (!this.passwordForm.current || !this.passwordForm.new) {
|
||||
this.toast.error('Please fill in all password fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.passwordForm.new !== this.passwordForm.confirm) {
|
||||
this.toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.passwordForm.new.length < 6) {
|
||||
this.toast.error('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.changePassword(
|
||||
this.passwordForm.current,
|
||||
this.passwordForm.new
|
||||
);
|
||||
if (response.success) {
|
||||
this.toast.success('Password changed');
|
||||
this.passwordForm = { current: '', new: '', confirm: '' };
|
||||
} else {
|
||||
this.toast.error(response.error || 'Failed to change password');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('Failed to change password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user