Files
onebox/ui/src/app/features/domains/domain-detail.component.ts
2025-11-24 19:52:35 +00:00

290 lines
11 KiB
TypeScript

import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
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: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
],
template: `
<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() && !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 api = inject(ApiService);
private toast = inject(ToastService);
domain = signal<IDomainDetail | null>(null);
services = signal<IService[]>([]);
loading = signal(false);
ngOnInit(): void {
const domainName = this.route.snapshot.paramMap.get('domain');
if (domainName) {
this.loadDomain(domainName);
this.loadServices(domainName);
}
}
async loadDomain(name: string): Promise<void> {
this.loading.set(true);
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();
}
getDaysRemaining(timestamp: number): number {
const now = Date.now();
return Math.floor((timestamp - now) / (1000 * 60 * 60 * 24));
}
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';
}
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');
}
}
}