import { Component, inject, signal, computed, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core'; import { Location } from '@angular/common'; import { ActivatedRoute, 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 { LogStreamService } from '../../core/services/log-stream.service'; import { WebSocketService } from '../../core/services/websocket.service'; import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types'; import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component'; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardDescriptionComponent, CardContentComponent, CardFooterComponent, } from '../../ui/card/card.component'; import { ButtonComponent } from '../../ui/button/button.component'; import { BadgeComponent } from '../../ui/badge/badge.component'; import { InputComponent } from '../../ui/input/input.component'; import { LabelComponent } from '../../ui/label/label.component'; import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; import { SeparatorComponent } from '../../ui/separator/separator.component'; import { DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, } from '../../ui/dialog/dialog.component'; @Component({ selector: 'app-service-detail', standalone: true, imports: [ FormsModule, RouterLink, CardComponent, CardHeaderComponent, CardTitleComponent, CardDescriptionComponent, CardContentComponent, CardFooterComponent, ButtonComponent, BadgeComponent, InputComponent, LabelComponent, SkeletonComponent, SeparatorComponent, DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, ContainerStatsComponent, ], template: `
Back to Services @if (loading() && !service()) { } @else if (service()) {

{{ service()!.name }}

{{ service()!.status }}
}
@if (loading() && !service()) {
@for (_ of [1,2,3,4]; track $index) { }
} @else if (service()) {
Service Details @if (!editMode()) { } @if (editMode()) {
} @else {
Image
{{ service()!.image }}
Port
{{ service()!.port }}
Domain
@if (service()!.domain) { {{ service()!.domain }} } @else { Not configured }
@if (service()!.containerID) {
Container ID
{{ service()!.containerID?.slice(0, 12) }}
}
Created
{{ formatDate(service()!.createdAt) }}
Updated
{{ formatDate(service()!.updatedAt) }}
}
Actions Manage service state
@if (service()!.status === 'stopped' || service()!.status === 'failed') { } @if (service()!.status === 'running') { }
@if (service()!.status === 'running') { } @if (service()!.envVars && getEnvKeys(service()!.envVars).length > 0) { Environment Variables
@for (key of getEnvKeys(service()!.envVars); track key) {
{{ key }}: {{ service()!.envVars[key] }}
}
} @if (service()!.platformRequirements || platformResources().length > 0) { Platform Resources Managed infrastructure provisioned for this service @if (platformResources().length > 0) { @for (resource of platformResources(); track resource.id) {
@if (resource.resourceType === 'database') { } @else if (resource.resourceType === 'bucket') { } {{ resource.resourceName }}
{{ resource.platformService.status }}
{{ resource.platformService.type === 'mongodb' ? 'MongoDB Database' : 'S3 Bucket (MinIO)' }}

Injected Environment Variables

@for (key of getEnvKeys(resource.envVars); track key) { {{ key }} }
} } @else if (service()!.platformRequirements) {
@if (service()!.platformRequirements!.mongodb) {

MongoDB database pending provisioning...

} @if (service()!.platformRequirements!.s3) {

S3 bucket pending provisioning...

}
}
} @if (service()!.useOneboxRegistry) { Onebox Registry Push images directly to this service
Repository
{{ service()!.registryRepository }}
Tag
{{ service()!.registryImageTag || 'latest' }}
Auto-update on push
{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}
} @if (!service()!.useOneboxRegistry) { Image Source External container registry
Registry
{{ imageInfo().registry }}
Repository
{{ imageInfo().repository }}
Tag
{{ imageInfo().tag }}
Full Image Reference
{{ service()!.image }}
}
Logs @if (logStream.isStreaming()) { Live streaming } @else { Container logs }
@if (logStream.isStreaming()) { } @else { }
@if (logStream.state().error) {

Error: {{ logStream.state().error }}

} @else if (logStream.logs().length > 0) { @for (line of logStream.logs(); track $index) {
{{ line }}
} } @else if (logStream.isStreaming()) {

Waiting for logs...

} @else {

Click "Stream" to start live log streaming

}
}
Delete Service Are you sure you want to delete "{{ service()?.name }}"? This action cannot be undone. `, }) export class ServiceDetailComponent implements OnInit, OnDestroy { private location = inject(Location); private route = inject(ActivatedRoute); private router = inject(Router); private api = inject(ApiService); private toast = inject(ToastService); private ws = inject(WebSocketService); logStream = inject(LogStreamService); @ViewChild('logContainer') logContainer!: ElementRef; service = signal(null); platformResources = signal([]); stats = signal(null); metrics = signal([]); loading = signal(false); actionLoading = signal(false); editMode = signal(false); deleteDialogOpen = signal(false); autoScroll = true; editForm: IServiceUpdate = {}; // Computed signal for parsed image information (external registries) imageInfo = computed(() => { const svc = this.service(); if (!svc) return { registry: '', repository: '', tag: '' }; return this.parseImageInfo(svc.image, svc.registry); }); constructor() { // Auto-scroll when new logs arrive effect(() => { const logs = this.logStream.logs(); if (logs.length > 0 && this.autoScroll && this.logContainer?.nativeElement) { setTimeout(() => { const container = this.logContainer.nativeElement; container.scrollTop = container.scrollHeight; }, 0); } }); // Listen for WebSocket stats updates effect(() => { const update = this.ws.statsUpdate(); const currentService = this.service(); if (update && currentService && update.serviceName === currentService.name) { this.stats.set(update.stats); } }); } ngOnInit(): void { const name = this.route.snapshot.paramMap.get('name'); if (name) { this.loadService(name); } } goBack(): void { this.location.back(); } ngOnDestroy(): void { this.logStream.disconnect(); } async loadService(name: string): Promise { this.loading.set(true); try { const response = await this.api.getService(name); if (response.success && response.data) { this.service.set(response.data); this.editForm = { image: response.data.image, port: response.data.port, domain: response.data.domain, }; // Load platform resources if service has platform requirements if (response.data.platformRequirements) { this.loadPlatformResources(name); } // Load initial stats and metrics if service is running // (WebSocket will keep stats updated in real-time) if (response.data.status === 'running') { this.loadStats(name); this.loadMetrics(name); } else { // Clear stats if service not running this.stats.set(null); this.metrics.set([]); } } else { this.toast.error(response.error || 'Service not found'); this.router.navigate(['/services']); } } catch { this.toast.error('Failed to load service'); } finally { this.loading.set(false); } } async loadPlatformResources(name: string): Promise { try { const response = await this.api.getServicePlatformResources(name); if (response.success && response.data) { this.platformResources.set(response.data); } } catch { // Silent fail - platform resources are optional } } async loadStats(name: string): Promise { try { const response = await this.api.getServiceStats(name); if (response.success && response.data) { this.stats.set(response.data); } } catch { // Silent fail - stats are optional } } async loadMetrics(name: string): Promise { try { const response = await this.api.getServiceMetrics(name, 60); if (response.success && response.data) { this.metrics.set(response.data); } } catch { // Silent fail - metrics are optional } } startLogStream(): void { const name = this.service()?.name; if (name) { this.logStream.connect(name); } } stopLogStream(): void { this.logStream.disconnect(); } clearLogs(): void { this.logStream.clearLogs(); } 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'; } } formatDate(timestamp: number): string { return new Date(timestamp).toLocaleString(); } getEnvKeys(envVars: Record): string[] { return Object.keys(envVars); } parseImageInfo(image: string, registry?: string): { registry: string; repository: string; tag: string } { // Handle digest format: image@sha256:... let imageWithoutDigest = image; if (image.includes('@')) { imageWithoutDigest = image.split('@')[0]; } // Split tag: image:tag const tagIndex = imageWithoutDigest.lastIndexOf(':'); let repository = imageWithoutDigest; let tag = 'latest'; if (tagIndex > 0 && !imageWithoutDigest.substring(tagIndex).includes('/')) { repository = imageWithoutDigest.substring(0, tagIndex); tag = imageWithoutDigest.substring(tagIndex + 1); } // Parse registry from repository let parsedRegistry = registry || 'Docker Hub'; if (!registry && repository.includes('/')) { const firstPart = repository.split('/')[0]; // If first part looks like a registry (contains . or :) if (firstPart.includes('.') || firstPart.includes(':')) { parsedRegistry = firstPart; repository = repository.substring(firstPart.length + 1); } } return { registry: parsedRegistry, repository, tag }; } cancelEdit(): void { this.editMode.set(false); if (this.service()) { this.editForm = { image: this.service()!.image, port: this.service()!.port, domain: this.service()!.domain, }; } } async saveChanges(): Promise { const name = this.service()?.name; if (!name) return; this.actionLoading.set(true); try { const response = await this.api.updateService(name, this.editForm); if (response.success) { this.toast.success('Service updated'); this.editMode.set(false); this.loadService(name); } else { this.toast.error(response.error || 'Failed to update service'); } } catch { this.toast.error('Failed to update service'); } finally { this.actionLoading.set(false); } } async startService(): Promise { const name = this.service()?.name; if (!name) return; this.actionLoading.set(true); try { const response = await this.api.startService(name); if (response.success) { this.toast.success('Service started'); this.loadService(name); } else { this.toast.error(response.error || 'Failed to start service'); } } catch { this.toast.error('Failed to start service'); } finally { this.actionLoading.set(false); } } async stopService(): Promise { const name = this.service()?.name; if (!name) return; this.actionLoading.set(true); try { const response = await this.api.stopService(name); if (response.success) { this.toast.success('Service stopped'); this.loadService(name); } else { this.toast.error(response.error || 'Failed to stop service'); } } catch { this.toast.error('Failed to stop service'); } finally { this.actionLoading.set(false); } } async restartService(): Promise { const name = this.service()?.name; if (!name) return; this.actionLoading.set(true); try { const response = await this.api.restartService(name); if (response.success) { this.toast.success('Service restarted'); this.loadService(name); } else { this.toast.error(response.error || 'Failed to restart service'); } } catch { this.toast.error('Failed to restart service'); } finally { this.actionLoading.set(false); } } async deleteService(): Promise { const name = this.service()?.name; if (!name) return; this.actionLoading.set(true); try { const response = await this.api.deleteService(name); if (response.success) { this.toast.success('Service deleted'); this.router.navigate(['/services']); } else { this.toast.error(response.error || 'Failed to delete service'); } } catch { this.toast.error('Failed to delete service'); } finally { this.actionLoading.set(false); this.deleteDialogOpen.set(false); } } }