Files
onebox/ui/src/app/features/services/platform-service-detail.component.ts

427 lines
17 KiB
TypeScript
Raw Normal View History

import { Component, inject, signal, OnInit, OnDestroy, effect, ViewChild, ElementRef } from '@angular/core';
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 { WebSocketService } from '../../core/services/websocket.service';
import { LogStreamService } from '../../core/services/log-stream.service';
import { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types';
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
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 { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-platform-service-detail',
standalone: true,
imports: [
RouterLink,
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
SkeletonComponent,
ContainerStatsComponent,
],
template: `
<div class="space-y-6">
<!-- Header -->
<div>
<a routerLink="/services" 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 Services
</a>
@if (loading() && !service()) {
<ui-skeleton class="h-9 w-48" />
} @else if (service()) {
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold tracking-tight">{{ service()!.displayName }}</h1>
<ui-badge [variant]="getStatusVariant(service()!.status)">{{ service()!.status }}</ui-badge>
@if (service()!.isCore) {
<ui-badge variant="outline">Core Service</ui-badge>
}
</div>
}
</div>
@if (loading() && !service()) {
<div class="grid gap-6 md:grid-cols-2">
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-skeleton class="h-6 w-32" />
</ui-card-header>
<ui-card-content class="space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-4 w-full" />
}
</ui-card-content>
</ui-card>
</div>
} @else if (service()) {
<div class="grid gap-6 md:grid-cols-2">
<!-- Service Details -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Service Details</ui-card-title>
<ui-card-description>Platform service information</ui-card-description>
</ui-card-header>
<ui-card-content>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-muted-foreground">Type</dt>
<dd class="text-sm">{{ service()!.type }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Resource Types</dt>
<dd class="text-sm">
<div class="flex flex-wrap gap-1 mt-1">
@for (type of service()!.resourceTypes; track type) {
<ui-badge variant="outline">{{ type }}</ui-badge>
}
</div>
</dd>
</div>
@if (service()!.containerId) {
<div>
<dt class="text-sm font-medium text-muted-foreground">Container ID</dt>
<dd class="text-sm font-mono">{{ service()!.containerId?.slice(0, 12) }}</dd>
</div>
}
@if (service()!.createdAt) {
<div>
<dt class="text-sm font-medium text-muted-foreground">Created</dt>
<dd class="text-sm">{{ formatDate(service()!.createdAt!) }}</dd>
</div>
}
</dl>
</ui-card-content>
</ui-card>
<!-- Actions -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Actions</ui-card-title>
<ui-card-description>Manage platform service state</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
@if (service()!.isCore) {
<p class="text-sm text-muted-foreground">
This is a core service managed by Onebox. It cannot be stopped manually.
</p>
} @else {
<div class="flex flex-wrap gap-2">
@if (service()!.status === 'stopped' || service()!.status === 'not-deployed' || service()!.status === 'failed') {
<button uiButton (click)="startService()" [disabled]="actionLoading()">
@if (actionLoading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
}
Start Service
</button>
}
@if (service()!.status === 'running') {
<button uiButton variant="outline" (click)="stopService()" [disabled]="actionLoading()">
Stop Service
</button>
}
</div>
}
</ui-card-content>
</ui-card>
<!-- Resource Stats (only shown when service is running) -->
@if (service()!.status === 'running') {
<app-container-stats [stats]="stats()" [showLiveIndicator]="true" />
}
<!-- Service Description -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>About {{ service()!.displayName }}</ui-card-title>
</ui-card-header>
<ui-card-content>
<p class="text-sm text-muted-foreground">{{ getServiceDescription(service()!.type) }}</p>
</ui-card-content>
</ui-card>
</div>
<!-- Logs Section -->
@if (service()!.status === 'running') {
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between">
<div class="flex flex-col space-y-1.5">
<ui-card-title>Logs</ui-card-title>
<ui-card-description>
@if (logStream.isStreaming()) {
<span class="flex items-center gap-2">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Live streaming
</span>
} @else {
Container logs
}
</ui-card-description>
</div>
<div class="flex items-center gap-2">
@if (logStream.isStreaming()) {
<button uiButton variant="outline" size="sm" (click)="stopLogStream()">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Stop
</button>
} @else {
<button uiButton variant="outline" size="sm" (click)="startLogStream()">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Stream
</button>
}
<button uiButton variant="ghost" size="sm" (click)="clearLogs()" title="Clear logs">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<label class="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" [(ngModel)]="autoScroll" class="rounded border-input" />
Auto-scroll
</label>
</div>
</ui-card-header>
<ui-card-content>
<div
#logContainer
class="bg-zinc-950 text-zinc-100 rounded-md p-4 h-96 overflow-auto font-mono text-xs"
>
@if (logStream.state().error) {
<p class="text-red-400">Error: {{ logStream.state().error }}</p>
} @else if (logStream.logs().length > 0) {
@for (line of logStream.logs(); track $index) {
<div class="whitespace-pre-wrap hover:bg-zinc-800/50">{{ line }}</div>
}
} @else if (logStream.isStreaming()) {
<p class="text-zinc-500">Waiting for logs...</p>
} @else {
<p class="text-zinc-500">Click "Stream" to start live log streaming</p>
}
</div>
</ui-card-content>
</ui-card>
}
}
</div>
`,
})
export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
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<HTMLDivElement>;
service = signal<IPlatformService | null>(null);
stats = signal<IContainerStats | null>(null);
loading = signal(false);
actionLoading = signal(false);
autoScroll = true;
private statsInterval: any;
constructor() {
// Listen for WebSocket stats updates for platform services
effect(() => {
const update = this.ws.statsUpdate();
const currentService = this.service();
// Platform services use "onebox-{type}" as service name in WebSocket
if (update && currentService && update.serviceName === `onebox-${currentService.type}`) {
this.stats.set(update.stats);
}
});
// Auto-scroll when new logs arrive
effect(() => {
const logs = this.logStream.logs();
if (logs.length > 0 && this.autoScroll && this.logContainer?.nativeElement) {
setTimeout(() => {
this.logContainer.nativeElement.scrollTop = this.logContainer.nativeElement.scrollHeight;
});
}
});
}
ngOnInit(): void {
const type = this.route.snapshot.paramMap.get('type') as TPlatformServiceType;
if (type) {
this.loadService(type);
}
}
async loadService(type: TPlatformServiceType): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getPlatformService(type);
if (response.success && response.data) {
this.service.set(response.data);
// Load stats if service is running
if (response.data.status === 'running') {
this.loadStats(type);
// Start polling stats every 5 seconds
this.startStatsPolling(type);
}
} else {
this.toast.error(response.error || 'Platform service not found');
this.router.navigate(['/services']);
}
} catch {
this.toast.error('Failed to load platform service');
} finally {
this.loading.set(false);
}
}
async loadStats(type: TPlatformServiceType): Promise<void> {
try {
const response = await this.api.getPlatformServiceStats(type);
if (response.success && response.data) {
this.stats.set(response.data);
}
} catch {
// Silent fail - stats are optional
}
}
startStatsPolling(type: TPlatformServiceType): void {
// Clear existing interval if any
if (this.statsInterval) {
clearInterval(this.statsInterval);
}
// Poll every 5 seconds
this.statsInterval = setInterval(() => {
if (this.service()?.status === 'running') {
this.loadStats(type);
}
}, 5000);
}
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
switch (status) {
case 'running': return 'success';
case 'stopped':
case 'not-deployed': return 'secondary';
case 'failed': return 'destructive';
case 'starting':
case 'stopping': return 'warning';
default: return 'secondary';
}
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
getServiceDescription(type: TPlatformServiceType): string {
const descriptions: Record<TPlatformServiceType, string> = {
mongodb: 'MongoDB is a document-oriented NoSQL database used for high volume data storage. It stores data in flexible, JSON-like documents.',
minio: 'MinIO is a high-performance, S3-compatible object storage service. Use it to store unstructured data like photos, videos, log files, and backups.',
redis: 'Redis is an in-memory data structure store, used as a distributed cache, message broker, and key-value database.',
postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.',
rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.',
caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.',
};
return descriptions[type] || 'A platform service managed by Onebox.';
}
async startService(): Promise<void> {
const type = this.service()?.type;
if (!type) return;
this.actionLoading.set(true);
try {
const response = await this.api.startPlatformService(type);
if (response.success) {
this.toast.success('Platform service started');
this.loadService(type);
} else {
this.toast.error(response.error || 'Failed to start platform service');
}
} catch {
this.toast.error('Failed to start platform service');
} finally {
this.actionLoading.set(false);
}
}
async stopService(): Promise<void> {
const type = this.service()?.type;
if (!type) return;
this.actionLoading.set(true);
try {
const response = await this.api.stopPlatformService(type);
if (response.success) {
this.toast.success('Platform service stopped');
// Clear stats and stop polling
this.stats.set(null);
if (this.statsInterval) {
clearInterval(this.statsInterval);
this.statsInterval = null;
}
this.loadService(type);
} else {
this.toast.error(response.error || 'Failed to stop platform service');
}
} catch {
this.toast.error('Failed to stop platform service');
} finally {
this.actionLoading.set(false);
}
}
ngOnDestroy(): void {
this.logStream.disconnect();
if (this.statsInterval) {
clearInterval(this.statsInterval);
}
}
startLogStream(): void {
const type = this.service()?.type;
if (type) {
this.logStream.connectPlatform(type);
}
}
stopLogStream(): void {
this.logStream.disconnect();
}
clearLogs(): void {
this.logStream.clearLogs();
}
}