382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
|
|
import { Component, inject, signal, OnInit, effect } from '@angular/core';
|
||
|
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||
|
|
import { ApiService } from '../../core/services/api.service';
|
||
|
|
import { ToastService } from '../../core/services/toast.service';
|
||
|
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||
|
|
import { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types';
|
||
|
|
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,
|
||
|
|
CardComponent,
|
||
|
|
CardHeaderComponent,
|
||
|
|
CardTitleComponent,
|
||
|
|
CardDescriptionComponent,
|
||
|
|
CardContentComponent,
|
||
|
|
ButtonComponent,
|
||
|
|
BadgeComponent,
|
||
|
|
SkeletonComponent,
|
||
|
|
],
|
||
|
|
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' && stats()) {
|
||
|
|
<ui-card>
|
||
|
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||
|
|
<ui-card-title>Resource Usage</ui-card-title>
|
||
|
|
<ui-card-description>
|
||
|
|
<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 stats
|
||
|
|
</span>
|
||
|
|
</ui-card-description>
|
||
|
|
</ui-card-header>
|
||
|
|
<ui-card-content>
|
||
|
|
<div class="grid grid-cols-2 gap-4">
|
||
|
|
<!-- CPU -->
|
||
|
|
<div class="space-y-2">
|
||
|
|
<div class="flex items-center justify-between text-sm">
|
||
|
|
<span class="text-muted-foreground">CPU</span>
|
||
|
|
<span class="font-medium">{{ formatPercent(stats()!.cpuPercent) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="h-2 bg-muted rounded-full overflow-hidden">
|
||
|
|
<div class="h-full bg-primary transition-all duration-300" [style.width.%]="stats()!.cpuPercent"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Memory -->
|
||
|
|
<div class="space-y-2">
|
||
|
|
<div class="flex items-center justify-between text-sm">
|
||
|
|
<span class="text-muted-foreground">Memory</span>
|
||
|
|
<span class="font-medium">{{ formatBytes(stats()!.memoryUsed) }} / {{ formatBytes(stats()!.memoryLimit) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="h-2 bg-muted rounded-full overflow-hidden">
|
||
|
|
<div class="h-full bg-primary transition-all duration-300" [style.width.%]="stats()!.memoryPercent"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Network RX -->
|
||
|
|
<div class="space-y-1">
|
||
|
|
<div class="flex items-center justify-between text-sm">
|
||
|
|
<span class="text-muted-foreground">Network In</span>
|
||
|
|
<span class="font-medium">{{ formatBytes(stats()!.networkRx) }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Network TX -->
|
||
|
|
<div class="space-y-1">
|
||
|
|
<div class="flex items-center justify-between text-sm">
|
||
|
|
<span class="text-muted-foreground">Network Out</span>
|
||
|
|
<span class="font-medium">{{ formatBytes(stats()!.networkTx) }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ui-card-content>
|
||
|
|
</ui-card>
|
||
|
|
}
|
||
|
|
|
||
|
|
<!-- 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>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
`,
|
||
|
|
})
|
||
|
|
export class PlatformServiceDetailComponent implements OnInit {
|
||
|
|
private route = inject(ActivatedRoute);
|
||
|
|
private router = inject(Router);
|
||
|
|
private api = inject(ApiService);
|
||
|
|
private toast = inject(ToastService);
|
||
|
|
private ws = inject(WebSocketService);
|
||
|
|
|
||
|
|
service = signal<IPlatformService | null>(null);
|
||
|
|
stats = signal<IContainerStats | null>(null);
|
||
|
|
loading = signal(false);
|
||
|
|
actionLoading = signal(false);
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
formatBytes(bytes: number): string {
|
||
|
|
if (bytes === 0) return '0 B';
|
||
|
|
const k = 1024;
|
||
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
|
|
}
|
||
|
|
|
||
|
|
formatPercent(value: number): string {
|
||
|
|
return value.toFixed(1) + '%';
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|