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 }}
@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);
}
}
}