import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ApiService, Service } from '../../core/services/api.service'; import { ToastService } from '../../core/services/toast.service'; interface EnvVar { key: string; value: string; } interface Domain { domain: string; dnsProvider: 'cloudflare' | 'manual' | null; isObsolete: boolean; } @Component({ selector: 'app-service-detail', standalone: true, imports: [CommonModule, FormsModule, RouterLink], template: `
@if (loading()) {
} @else if (service()) {

{{ service()!.name }}

{{ service()!.status }}

Service Details

@if (!isEditing()) { }
@if (!isEditing()) {
Image
{{ service()!.image }}
Port
{{ service()!.port }}
@if (service()!.domain) {
Domain
{{ service()!.domain }}
} @if (service()!.containerID) {
Container ID
{{ service()!.containerID?.substring(0, 12) }}
}
Created
{{ formatDate(service()!.createdAt) }}
Updated
{{ formatDate(service()!.updatedAt) }}
@if (service()!.useOneboxRegistry) {

Onebox Registry

Repository
{{ service()!.registryRepository }}
Tag
{{ service()!.registryImageTag || 'latest' }}
@if (service()!.registryToken) {
Push/Pull Token

Use this token to push images: docker login -u unused -p [token] {{ registryBaseUrl() }}

}
Auto-update
{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}
@if (service()!.imageDigest) {
Current Digest
{{ service()!.imageDigest }}
}
} @if (Object.keys(service()!.envVars).length > 0) {

Environment Variables

@for (entry of Object.entries(service()!.envVars); track entry[0]) {
{{ entry[0] }} {{ entry[1] }}
}
} } @else {

Format: image:tag or registry/image:tag

Port that your application listens on

@for (domain of availableDomains(); track domain.domain) { } @if (domainWarning()) {

{{ domainWarningTitle() }}

{{ domainWarningMessage() }}

} @else {

Leave empty to skip automatic DNS & SSL. @if (availableDomains().length > 0) { Or select from {{ availableDomains().length }} available domain(s). }

}
@for (env of editEnvVars(); track $index) {
}
@if (error()) {

{{ error() }}

}
}
@if (!isEditing()) {

Actions

@if (service()!.status === 'stopped') { } @if (service()!.status === 'running') { }
} @if (!isEditing()) {

Logs

@if (loadingLogs()) {
} @else {
@if (filteredLogs().length === 0) {

No logs available

} @else { @for (line of filteredLogs(); track $index) {
{{ line }}
} }
} @if (filteredLogs().length > 0 && filteredLogs().length !== logLines().length) {
Showing {{ filteredLogs().length }} of {{ logLines().length }} lines
}
} }
`, }) export class ServiceDetailComponent implements OnInit, OnDestroy { private apiService = inject(ApiService); private route = inject(ActivatedRoute); private router = inject(Router); service = signal(null); logs = signal(''); logLines = signal([]); filteredLogs = signal([]); logSearch = ''; logLevelFilter = 'all'; logsAutoRefresh = false; private logsRefreshInterval?: number; loading = signal(true); loadingLogs = signal(false); // Edit mode isEditing = signal(false); saving = signal(false); error = signal(''); editForm = { image: '', port: 80, domain: '', }; editEnvVars = signal([]); // Domain validation availableDomains = signal([]); domainWarning = signal(false); domainWarningTitle = signal(''); domainWarningMessage = signal(''); Object = Object; ngOnInit(): void { const name = this.route.snapshot.paramMap.get('name')!; this.loadService(name); this.loadLogs(name); this.loadDomains(); } loadService(name: string): void { this.loading.set(true); this.apiService.getService(name).subscribe({ next: (response) => { if (response.success && response.data) { this.service.set(response.data); } this.loading.set(false); }, error: () => { this.loading.set(false); this.router.navigate(['/services']); }, }); } loadLogs(name: string): void { this.loadingLogs.set(true); this.apiService.getServiceLogs(name).subscribe({ next: (response) => { if (response.success && response.data) { this.logs.set(response.data); const lines = response.data.split('\n').filter((line: string) => line.trim()); this.logLines.set(lines); this.filterLogs(); } this.loadingLogs.set(false); }, error: () => { this.loadingLogs.set(false); }, }); } filterLogs(): void { let lines = this.logLines(); // Apply level filter if (this.logLevelFilter !== 'all') { lines = lines.filter(line => this.isLogLevel(line, this.logLevelFilter)); } // Apply search filter if (this.logSearch.trim()) { const searchLower = this.logSearch.toLowerCase(); lines = lines.filter(line => line.toLowerCase().includes(searchLower)); } this.filteredLogs.set(lines); } isLogLevel(line: string, level: string): boolean { const lineLower = line.toLowerCase(); if (level === 'error') return lineLower.includes('error') || lineLower.includes('✖'); if (level === 'warn') return lineLower.includes('warn') || lineLower.includes('warning'); if (level === 'info') return lineLower.includes('info') || lineLower.includes('ℹ'); if (level === 'debug') return lineLower.includes('debug'); return false; } hasLogLevel(line: string): boolean { return this.isLogLevel(line, 'error') || this.isLogLevel(line, 'warn') || this.isLogLevel(line, 'info') || this.isLogLevel(line, 'debug'); } toggleLogsAutoRefresh(): void { if (this.logsAutoRefresh) { this.logsRefreshInterval = window.setInterval(() => { this.refreshLogs(); }, 5000); // Refresh every 5 seconds } else { if (this.logsRefreshInterval) { clearInterval(this.logsRefreshInterval); this.logsRefreshInterval = undefined; } } } loadDomains(): void { this.apiService.getDomains().subscribe({ next: (response) => { if (response.success && response.data) { const domains: Domain[] = response.data.map((d: any) => ({ domain: d.domain.domain, dnsProvider: d.domain.dnsProvider, isObsolete: d.domain.isObsolete, })); this.availableDomains.set(domains); } }, error: () => { // Silently fail - domains list not critical }, }); } startEditing(): void { const svc = this.service()!; this.editForm.image = svc.image; this.editForm.port = svc.port; this.editForm.domain = svc.domain || ''; // Convert env vars to array const envVars: EnvVar[] = []; for (const [key, value] of Object.entries(svc.envVars || {})) { envVars.push({ key, value }); } this.editEnvVars.set(envVars); this.isEditing.set(true); this.error.set(''); } cancelEditing(): void { this.isEditing.set(false); this.error.set(''); this.domainWarning.set(false); } saveService(): void { this.error.set(''); this.saving.set(true); // Convert env vars to object const envVarsObj: Record = {}; for (const env of this.editEnvVars()) { if (env.key && env.value) { envVarsObj[env.key] = env.value; } } const updates = { image: this.editForm.image, port: this.editForm.port, domain: this.editForm.domain || undefined, envVars: envVarsObj, }; this.apiService.updateService(this.service()!.name, updates).subscribe({ next: (response) => { this.saving.set(false); if (response.success) { this.service.set(response.data!); this.isEditing.set(false); } else { this.error.set(response.error || 'Failed to update service'); } }, error: (err) => { this.saving.set(false); this.error.set(err.error?.error || 'An error occurred'); }, }); } addEnvVar(): void { this.editEnvVars.update((vars) => [...vars, { key: '', value: '' }]); } removeEnvVar(index: number): void { this.editEnvVars.update((vars) => vars.filter((_, i) => i !== index)); } onDomainChange(): void { if (!this.editForm.domain) { this.domainWarning.set(false); return; } // Extract base domain from entered domain const parts = this.editForm.domain.split('.'); if (parts.length < 2) { this.domainWarning.set(false); return; } const baseDomain = parts.slice(-2).join('.'); // Check if base domain exists in available domains const matchingDomain = this.availableDomains().find( (d) => d.domain === baseDomain ); if (!matchingDomain) { this.domainWarning.set(true); this.domainWarningTitle.set('Domain not found'); this.domainWarningMessage.set( `The base domain "${baseDomain}" is not in the Domain table. The service will update, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.` ); } else if (matchingDomain.isObsolete) { this.domainWarning.set(true); this.domainWarningTitle.set('Domain is obsolete'); this.domainWarningMessage.set( `The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.` ); } else { this.domainWarning.set(false); } } refreshLogs(): void { this.loadLogs(this.service()!.name); } startService(): void { this.apiService.startService(this.service()!.name).subscribe({ next: () => { this.loadService(this.service()!.name); }, }); } stopService(): void { this.apiService.stopService(this.service()!.name).subscribe({ next: () => { this.loadService(this.service()!.name); }, }); } restartService(): void { this.apiService.restartService(this.service()!.name).subscribe({ next: () => { this.loadService(this.service()!.name); }, }); } deleteService(): void { if (confirm(`Are you sure you want to delete ${this.service()!.name}?`)) { this.apiService.deleteService(this.service()!.name).subscribe({ next: () => { this.router.navigate(['/services']); }, }); } } formatDate(timestamp: number): string { return new Date(timestamp).toLocaleString(); } private toastService = inject(ToastService); copyToken(token: string): void { navigator.clipboard.writeText(token).then(() => { this.toastService.success('Token copied to clipboard!'); }).catch(() => { this.toastService.error('Failed to copy token'); }); } registryBaseUrl = signal('localhost:5000'); ngOnDestroy(): void { if (this.logsRefreshInterval) { clearInterval(this.logsRefreshInterval); } } }