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) {
}
@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) {
}
- 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 {
}
@if (!isEditing()) {
Actions
@if (service()!.status === 'stopped') {
}
@if (service()!.status === 'running') {
}
}
@if (!isEditing()) {
@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);
}
}
}