feat(platform-services): Add platform service log streaming, improve health checks and provisioning robustness
This commit is contained in:
@@ -117,6 +117,7 @@ export class LogStreamService {
|
||||
}
|
||||
this.currentService = null;
|
||||
this.isStreaming.set(false);
|
||||
this.logs.set([]); // Clear logs when disconnecting to prevent stale logs showing on next service
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: null,
|
||||
@@ -137,4 +138,90 @@ export class LogStreamService {
|
||||
getCurrentService(): string | null {
|
||||
return this.currentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to log stream for a platform service (MongoDB, MinIO, etc.)
|
||||
*/
|
||||
connectPlatform(type: string): void {
|
||||
// Disconnect any existing stream
|
||||
this.disconnect();
|
||||
|
||||
this.currentService = `platform:${type}`;
|
||||
this.isStreaming.set(true);
|
||||
this.logs.set([]);
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: null,
|
||||
serviceName: type,
|
||||
});
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const url = `${protocol}//${host}/api/platform-services/${type}/logs/stream`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Connection established, waiting for 'connected' message from server
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
|
||||
// Try to parse as JSON (for control messages)
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
|
||||
if (json.type === 'connected') {
|
||||
this.state.set({
|
||||
connected: true,
|
||||
error: null,
|
||||
serviceName: json.serviceName || type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
this.state.update((s) => ({ ...s, error: json.error }));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON - it's a log line
|
||||
this.logs.update((lines) => {
|
||||
const newLines = [...lines, data];
|
||||
// Keep last 1000 lines to prevent memory issues
|
||||
if (newLines.length > 1000) {
|
||||
return newLines.slice(-1000);
|
||||
}
|
||||
return newLines;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.state.update((s) => ({ ...s, connected: false }));
|
||||
this.isStreaming.set(false);
|
||||
this.ws = null;
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.state.update((s) => ({
|
||||
...s,
|
||||
connected: false,
|
||||
error: 'WebSocket connection failed',
|
||||
}));
|
||||
this.isStreaming.set(false);
|
||||
};
|
||||
} catch (error) {
|
||||
this.state.set({
|
||||
connected: false,
|
||||
error: 'Failed to connect to log stream',
|
||||
serviceName: type,
|
||||
});
|
||||
this.isStreaming.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component, inject, signal, OnInit, effect } from '@angular/core';
|
||||
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 {
|
||||
@@ -21,6 +23,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
@@ -157,21 +160,95 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
</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 {
|
||||
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;
|
||||
|
||||
@@ -185,6 +262,16 @@ export class PlatformServiceDetailComponent implements OnInit {
|
||||
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 {
|
||||
@@ -314,4 +401,26 @@ export class PlatformServiceDetailComponent implements OnInit {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user