From 3fbcaee56e1731e8b4cc5e0b644250cb32441e82 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 26 Nov 2025 18:20:02 +0000 Subject: [PATCH] feat(platform-services): Add platform service log streaming, improve health checks and provisioning robustness --- changelog.md | 12 ++ ts/00_commitinfo_data.ts | 8 ++ ts/classes/docker.ts | 64 ++++++++- ts/classes/httpserver.ts | 123 ++++++++++++++++++ ts/classes/platform-services/manager.ts | 22 +++- .../platform-services/providers/minio.ts | 15 ++- .../platform-services/providers/mongodb.ts | 119 ++++++++++++----- .../app/core/services/log-stream.service.ts | 87 +++++++++++++ .../platform-service-detail.component.ts | 113 +++++++++++++++- 9 files changed, 515 insertions(+), 48 deletions(-) create mode 100644 ts/00_commitinfo_data.ts diff --git a/changelog.md b/changelog.md index b76f35f..a562007 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-11-26 - 1.1.0 - feat(platform-services) +Add platform service log streaming, improve health checks and provisioning robustness + +- Add WebSocket log streaming support for platform services (backend + UI) to stream MinIO/MongoDB/Caddy logs in real time +- Improve platform service lifecycle: detect unhealthy 'running' containers, mark for redeploy and wait/retry health checks with detailed logging +- MinIO health check now uses container IP (via Docker) instead of hostname to reliably probe the service +- MongoDB and MinIO providers updated to use host-mapped ports for host-side provisioning and connect via 127.0.0.1: +- Docker manager: pullImage now actively pulls images and createContainer binds service ports to localhost so host-based provisioning works +- UI: platform service detail page can start/stop/clear platform log streams; log stream service state cleared on disconnect to avoid stale logs +- Caddy / reverse-proxy improvements to manage certificates and routes via the Caddy manager (Caddy runs as Docker service) +- Add VSCode workspace helpers (extensions, launch, tasks) to improve developer experience + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts new file mode 100644 index 0000000..c61e402 --- /dev/null +++ b/ts/00_commitinfo_data.ts @@ -0,0 +1,8 @@ +/** + * autocreated commitinfo by @push.rocks/commitinfo + */ +export const commitinfo = { + name: '@serve.zone/onebox', + version: '1.1.0', + description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' +} diff --git a/ts/classes/docker.ts b/ts/classes/docker.ts index 2983503..3c31a3b 100644 --- a/ts/classes/docker.ts +++ b/ts/classes/docker.ts @@ -78,9 +78,26 @@ export class OneboxDockerManager { * Pull an image from a registry */ async pullImage(image: string, registry?: string): Promise { - // Skip manual image pulling - Docker will automatically pull when creating container const fullImage = registry ? `${registry}/${image}` : image; - logger.debug(`Skipping manual pull for ${fullImage} - Docker will auto-pull on container creation`); + logger.info(`Pulling image: ${fullImage}`); + + try { + // Parse image name and tag (e.g., "nginx:alpine" -> imageUrl: "nginx", imageTag: "alpine") + const [imageUrl, imageTag] = fullImage.includes(':') + ? fullImage.split(':') + : [fullImage, 'latest']; + + // Use the library's built-in createImageFromRegistry method + await this.dockerClient!.createImageFromRegistry({ + imageUrl, + imageTag, + }); + + logger.success(`Image pulled successfully: ${fullImage}`); + } catch (error) { + logger.error(`Failed to pull image ${fullImage}: ${getErrorMessage(error)}`); + throw error; + } } /** @@ -796,6 +813,34 @@ export class OneboxDockerManager { } } + /** + * Get host port binding for a container's exposed port + * @returns The host port number, or null if not bound + */ + async getContainerHostPort(containerID: string, containerPort: number): Promise { + try { + const container = await this.dockerClient!.getContainerById(containerID); + + if (!container) { + throw new Error(`Container not found: ${containerID}`); + } + + const info = await container.inspect(); + + const portKey = `${containerPort}/tcp`; + const bindings = info.NetworkSettings.Ports?.[portKey]; + + if (bindings && bindings.length > 0 && bindings[0].HostPort) { + return parseInt(bindings[0].HostPort, 10); + } + + return null; + } catch (error) { + logger.error(`Failed to get container host port ${containerID}:${containerPort}: ${getErrorMessage(error)}`); + return null; + } + } + /** * Execute a command in a running container */ @@ -829,8 +874,11 @@ export class OneboxDockerManager { } }); - // Wait for completion - await new Promise((resolve) => stream.on('end', resolve)); + // Wait for completion with timeout + await Promise.race([ + new Promise((resolve) => stream.on('end', resolve)), + new Promise((_, reject) => setTimeout(() => reject(new Error('Exec timeout after 30s')), 30000)) + ]); const execInfo = await inspect(); const exitCode = execInfo.ExitCode || 0; @@ -859,6 +907,10 @@ export class OneboxDockerManager { try { logger.info(`Creating platform container: ${options.name}`); + // Pull the image first to ensure it's available + logger.info(`Pulling image for platform service: ${options.image}`); + await this.pullImage(options.image); + // Check if container already exists const existingContainers = await this.dockerClient!.listContainers(); const existing = existingContainers.find((c: any) => @@ -877,8 +929,8 @@ export class OneboxDockerManager { const portsToExpose = options.exposePorts || [options.port]; for (const port of portsToExpose) { exposedPorts[`${port}/tcp`] = {}; - // Don't bind to host ports by default - services communicate via Docker network - portBindings[`${port}/tcp`] = []; + // Bind to random host port so we can access from host (for provisioning) + portBindings[`${port}/tcp`] = [{ HostIp: '127.0.0.1', HostPort: '' }]; } // Prepare volume bindings diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index c37ee5e..3ea7fc7 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -83,6 +83,12 @@ export class OneboxHttpServer { return this.handleLogStreamUpgrade(req, serviceName); } + // Platform service log streaming WebSocket + if (path.startsWith('/api/platform-services/') && path.endsWith('/logs/stream') && req.headers.get('upgrade') === 'websocket') { + const platformType = path.split('/')[3]; + return this.handlePlatformLogStreamUpgrade(req, platformType); + } + // Network access logs WebSocket if (path === '/api/network/logs/stream' && req.headers.get('upgrade') === 'websocket') { return this.handleNetworkLogStreamUpgrade(req, new URL(req.url)); @@ -1060,6 +1066,123 @@ export class OneboxHttpServer { return response; } + /** + * Handle WebSocket upgrade for platform service log streaming + */ + private handlePlatformLogStreamUpgrade(req: Request, platformType: string): Response { + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = async () => { + logger.info(`Platform log stream WebSocket connected for: ${platformType}`); + + try { + // Get the platform service from database + const platformService = this.oneboxRef.database.getPlatformServiceByType(platformType as any); + if (!platformService) { + socket.send(JSON.stringify({ error: 'Platform service not found' })); + socket.close(); + return; + } + + if (!platformService.containerId) { + socket.send(JSON.stringify({ error: 'Platform service has no container' })); + socket.close(); + return; + } + + // Get the container + logger.info(`Looking up container for platform service ${platformType}, containerID: ${platformService.containerId}`); + const container = await this.oneboxRef.docker.getContainerById(platformService.containerId); + + if (!container) { + logger.error(`Container not found for platform service ${platformType}, containerID: ${platformService.containerId}`); + socket.send(JSON.stringify({ error: 'Container not found' })); + socket.close(); + return; + } + + // Start streaming logs + const logStream = await container.streamLogs({ + stdout: true, + stderr: true, + timestamps: true, + tail: 100, // Start with last 100 lines + }); + + // Send initial connection message + socket.send(JSON.stringify({ + type: 'connected', + serviceName: platformType, + })); + + // Demultiplex and pipe log data to WebSocket + // Docker streams use 8-byte headers: [STREAM_TYPE, 0, 0, 0, SIZE_BYTE1, SIZE_BYTE2, SIZE_BYTE3, SIZE_BYTE4] + let buffer = new Uint8Array(0); + + logStream.on('data', (chunk: Uint8Array) => { + if (socket.readyState !== WebSocket.OPEN) return; + + // Append new data to buffer + const newBuffer = new Uint8Array(buffer.length + chunk.length); + newBuffer.set(buffer); + newBuffer.set(chunk, buffer.length); + buffer = newBuffer; + + // Process complete frames + while (buffer.length >= 8) { + // Read frame size from header (bytes 4-7, big-endian) + const frameSize = (buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7]; + + // Check if we have the complete frame + if (buffer.length < 8 + frameSize) { + break; // Wait for more data + } + + // Extract the frame data (skip 8-byte header) + const frameData = buffer.slice(8, 8 + frameSize); + + // Send the clean log line + socket.send(new TextDecoder().decode(frameData)); + + // Remove processed frame from buffer + buffer = buffer.slice(8 + frameSize); + } + }); + + logStream.on('error', (error: Error) => { + logger.error(`Platform log stream error for ${platformType}: ${getErrorMessage(error)}`); + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ error: getErrorMessage(error) })); + } + }); + + logStream.on('end', () => { + logger.info(`Platform log stream ended for ${platformType}`); + socket.close(); + }); + + // Clean up on close + socket.onclose = () => { + logger.info(`Platform log stream WebSocket closed for ${platformType}`); + logStream.destroy(); + }; + + } catch (error) { + logger.error(`Failed to start platform log stream for ${platformType}: ${getErrorMessage(error)}`); + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ error: getErrorMessage(error) })); + socket.close(); + } + } + }; + + socket.onerror = (error) => { + logger.error(`Platform log stream WebSocket error: ${error}`); + }; + + return response; + } + /** * Handle WebSocket upgrade for network access log streaming */ diff --git a/ts/classes/platform-services/manager.ts b/ts/classes/platform-services/manager.ts index b936d6f..439c352 100644 --- a/ts/classes/platform-services/manager.ts +++ b/ts/classes/platform-services/manager.ts @@ -93,6 +93,8 @@ export class PlatformServicesManager { } // Check if already running + let needsDeploy = platformService.status !== 'running'; + if (platformService.status === 'running') { // Verify it's actually healthy const isHealthy = await provider.healthCheck(); @@ -100,11 +102,14 @@ export class PlatformServicesManager { logger.debug(`${provider.displayName} is already running and healthy`); return platformService; } - logger.warn(`${provider.displayName} reports running but health check failed, restarting...`); + logger.warn(`${provider.displayName} reports running but health check failed, will redeploy...`); + // Mark status as needing redeploy - container may have been recreated with different credentials + this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'stopped' }); + needsDeploy = true; } - // Deploy if not running - if (platformService.status !== 'running') { + // Deploy if needed + if (needsDeploy) { logger.info(`Starting ${provider.displayName} platform service...`); try { @@ -143,19 +148,28 @@ export class PlatformServicesManager { */ private async waitForHealthy(type: TPlatformServiceType, timeoutMs: number): Promise { const provider = this.providers.get(type); - if (!provider) return false; + if (!provider) { + logger.warn(`waitForHealthy: no provider for type ${type}`); + return false; + } + logger.info(`waitForHealthy: starting health check loop for ${type} (timeout: ${timeoutMs}ms)`); const startTime = Date.now(); const checkInterval = 2000; // Check every 2 seconds + let checkCount = 0; while (Date.now() - startTime < timeoutMs) { + checkCount++; + logger.info(`waitForHealthy: health check attempt #${checkCount} for ${type}`); const isHealthy = await provider.healthCheck(); if (isHealthy) { + logger.info(`waitForHealthy: ${type} became healthy after ${checkCount} attempts`); return true; } await new Promise((resolve) => setTimeout(resolve, checkInterval)); } + logger.warn(`waitForHealthy: ${type} did not become healthy after ${checkCount} attempts (${timeoutMs}ms)`); return false; } diff --git a/ts/classes/platform-services/providers/minio.ts b/ts/classes/platform-services/providers/minio.ts index 8550c25..0c558d2 100644 --- a/ts/classes/platform-services/providers/minio.ts +++ b/ts/classes/platform-services/providers/minio.ts @@ -118,8 +118,19 @@ export class MinioProvider extends BasePlatformServiceProvider { async healthCheck(): Promise { try { - const containerName = this.getContainerName(); - const endpoint = `http://${containerName}:9000/minio/health/live`; + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (!platformService || !platformService.containerId) { + return false; + } + + // Get container IP for health check (hostname won't resolve from host) + const containerIP = await this.oneboxRef.docker.getContainerIP(platformService.containerId); + if (!containerIP) { + logger.debug('MinIO health check: could not get container IP'); + return false; + } + + const endpoint = `http://${containerIP}:9000/minio/health/live`; const response = await fetch(endpoint, { method: 'GET', diff --git a/ts/classes/platform-services/providers/mongodb.ts b/ts/classes/platform-services/providers/mongodb.ts index 55a2d7c..ae53517 100644 --- a/ts/classes/platform-services/providers/mongodb.ts +++ b/ts/classes/platform-services/providers/mongodb.ts @@ -52,21 +52,52 @@ export class MongoDBProvider extends BasePlatformServiceProvider { async deployContainer(): Promise { const config = this.getDefaultConfig(); const containerName = this.getContainerName(); - - // Generate admin password - const adminPassword = credentialEncryption.generatePassword(32); - - // Store admin credentials encrypted in the platform service record - const adminCredentials = { - username: 'admin', - password: adminPassword, - }; + const dataDir = '/var/lib/onebox/mongodb'; logger.info(`Deploying MongoDB platform service as ${containerName}...`); + // Check if we have existing data and stored credentials + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + let adminCredentials: { username: string; password: string }; + let dataExists = false; + + // Check if data directory has existing MongoDB data + try { + const stat = await Deno.stat(`${dataDir}/WiredTiger`); + dataExists = stat.isFile; + logger.info(`MongoDB data directory exists with WiredTiger file`); + } catch { + // WiredTiger file doesn't exist, this is a fresh install + dataExists = false; + } + + if (dataExists && platformService?.adminCredentialsEncrypted) { + // Reuse existing credentials from database + logger.info('Reusing existing MongoDB credentials (data directory already initialized)'); + adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); + } else { + // Generate new credentials for fresh deployment + logger.info('Generating new MongoDB admin credentials'); + adminCredentials = { + username: 'admin', + password: credentialEncryption.generatePassword(32), + }; + + // If data exists but we don't have credentials, we need to wipe the data + if (dataExists) { + logger.warn('MongoDB data exists but no credentials in database - wiping data directory'); + try { + await Deno.remove(dataDir, { recursive: true }); + } catch (e) { + logger.error(`Failed to wipe MongoDB data directory: ${getErrorMessage(e)}`); + throw new Error('Cannot deploy MongoDB: data directory exists without credentials'); + } + } + } + // Ensure data directory exists try { - await Deno.mkdir('/var/lib/onebox/mongodb', { recursive: true }); + await Deno.mkdir(dataDir, { recursive: true }); } catch (e) { // Directory might already exist if (!(e instanceof Deno.errors.AlreadyExists)) { @@ -90,9 +121,8 @@ export class MongoDBProvider extends BasePlatformServiceProvider { network: this.getNetworkName(), }); - // Store encrypted admin credentials + // Store encrypted admin credentials (only update if new or changed) const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); - const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (platformService) { this.oneboxRef.database.updatePlatformService(platformService.id!, { containerId, @@ -113,43 +143,59 @@ export class MongoDBProvider extends BasePlatformServiceProvider { async healthCheck(): Promise { try { + logger.info('MongoDB health check: starting...'); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); - if (!platformService || !platformService.adminCredentialsEncrypted) { + if (!platformService) { + logger.info('MongoDB health check: platform service not found in database'); + return false; + } + if (!platformService.adminCredentialsEncrypted) { + logger.info('MongoDB health check: no admin credentials stored'); + return false; + } + if (!platformService.containerId) { + logger.info('MongoDB health check: no container ID in database record'); return false; } + logger.info(`MongoDB health check: using container ID ${platformService.containerId.substring(0, 12)}...`); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); - const containerName = this.getContainerName(); - // Try to connect to MongoDB using mongosh ping - const { MongoClient } = await import('npm:mongodb@6'); - const uri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; + // Use docker exec to run health check inside the container + // This avoids network issues with overlay networks + const result = await this.oneboxRef.docker.execInContainer( + platformService.containerId, + ['mongosh', '--eval', 'db.adminCommand("ping")', '--username', adminCreds.username, '--password', adminCreds.password, '--authenticationDatabase', 'admin', '--quiet'] + ); - const client = new MongoClient(uri, { - serverSelectionTimeoutMS: 5000, - connectTimeoutMS: 5000, - }); - - await client.connect(); - await client.db('admin').command({ ping: 1 }); - await client.close(); - - return true; + if (result.exitCode === 0) { + logger.info('MongoDB health check: success'); + return true; + } else { + logger.info(`MongoDB health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`); + return false; + } } catch (error) { - logger.debug(`MongoDB health check failed: ${getErrorMessage(error)}`); + logger.info(`MongoDB health check exception: ${getErrorMessage(error)}`); return false; } } async provisionResource(userService: IService): Promise { const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); - if (!platformService || !platformService.adminCredentialsEncrypted) { + if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) { throw new Error('MongoDB platform service not found or not configured'); } const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const containerName = this.getContainerName(); + // Get container host port for connection from host (overlay network IPs not accessible from host) + const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017); + if (!hostPort) { + throw new Error('Could not get MongoDB container host port'); + } + // Generate resource names and credentials const dbName = this.generateResourceName(userService.name); const username = this.generateResourceName(userService.name); @@ -157,9 +203,9 @@ export class MongoDBProvider extends BasePlatformServiceProvider { logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`); - // Connect to MongoDB and create database/user + // Connect to MongoDB via localhost and the mapped host port const { MongoClient } = await import('npm:mongodb@6'); - const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; + const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`; const client = new MongoClient(adminUri); await client.connect(); @@ -211,17 +257,22 @@ export class MongoDBProvider extends BasePlatformServiceProvider { async deprovisionResource(resource: IPlatformResource, credentials: Record): Promise { const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); - if (!platformService || !platformService.adminCredentialsEncrypted) { + if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) { throw new Error('MongoDB platform service not found or not configured'); } const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); - const containerName = this.getContainerName(); + + // Get container host port for connection from host (overlay network IPs not accessible from host) + const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017); + if (!hostPort) { + throw new Error('Could not get MongoDB container host port'); + } logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`); const { MongoClient } = await import('npm:mongodb@6'); - const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; + const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`; const client = new MongoClient(adminUri); await client.connect(); diff --git a/ui/src/app/core/services/log-stream.service.ts b/ui/src/app/core/services/log-stream.service.ts index e31153f..57862cf 100644 --- a/ui/src/app/core/services/log-stream.service.ts +++ b/ui/src/app/core/services/log-stream.service.ts @@ -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); + } + } } diff --git a/ui/src/app/features/services/platform-service-detail.component.ts b/ui/src/app/features/services/platform-service-detail.component.ts index ce37c37..c100ea2 100644 --- a/ui/src/app/features/services/platform-service-detail.component.ts +++ b/ui/src/app/features/services/platform-service-detail.component.ts @@ -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'; + + + @if (service()!.status === 'running') { + + +
+ 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

+ } +
+
+
+ } } `, }) -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; service = signal(null); stats = signal(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(); + } }