feat(platform-services): Add platform service log streaming, improve health checks and provisioning robustness

This commit is contained in:
2025-11-26 18:20:02 +00:00
parent 9de32cd00d
commit 3fbcaee56e
9 changed files with 515 additions and 48 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # 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:<hostPort>
- 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. 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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

8
ts/00_commitinfo_data.ts Normal file
View File

@@ -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'
}

View File

@@ -78,9 +78,26 @@ export class OneboxDockerManager {
* Pull an image from a registry * Pull an image from a registry
*/ */
async pullImage(image: string, registry?: string): Promise<void> { async pullImage(image: string, registry?: string): Promise<void> {
// Skip manual image pulling - Docker will automatically pull when creating container
const fullImage = registry ? `${registry}/${image}` : image; 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<number | null> {
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 * Execute a command in a running container
*/ */
@@ -829,8 +874,11 @@ export class OneboxDockerManager {
} }
}); });
// Wait for completion // Wait for completion with timeout
await new Promise((resolve) => stream.on('end', resolve)); await Promise.race([
new Promise<void>((resolve) => stream.on('end', resolve)),
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('Exec timeout after 30s')), 30000))
]);
const execInfo = await inspect(); const execInfo = await inspect();
const exitCode = execInfo.ExitCode || 0; const exitCode = execInfo.ExitCode || 0;
@@ -859,6 +907,10 @@ export class OneboxDockerManager {
try { try {
logger.info(`Creating platform container: ${options.name}`); 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 // Check if container already exists
const existingContainers = await this.dockerClient!.listContainers(); const existingContainers = await this.dockerClient!.listContainers();
const existing = existingContainers.find((c: any) => const existing = existingContainers.find((c: any) =>
@@ -877,8 +929,8 @@ export class OneboxDockerManager {
const portsToExpose = options.exposePorts || [options.port]; const portsToExpose = options.exposePorts || [options.port];
for (const port of portsToExpose) { for (const port of portsToExpose) {
exposedPorts[`${port}/tcp`] = {}; exposedPorts[`${port}/tcp`] = {};
// Don't bind to host ports by default - services communicate via Docker network // Bind to random host port so we can access from host (for provisioning)
portBindings[`${port}/tcp`] = []; portBindings[`${port}/tcp`] = [{ HostIp: '127.0.0.1', HostPort: '' }];
} }
// Prepare volume bindings // Prepare volume bindings

View File

@@ -83,6 +83,12 @@ export class OneboxHttpServer {
return this.handleLogStreamUpgrade(req, serviceName); 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 // Network access logs WebSocket
if (path === '/api/network/logs/stream' && req.headers.get('upgrade') === 'websocket') { if (path === '/api/network/logs/stream' && req.headers.get('upgrade') === 'websocket') {
return this.handleNetworkLogStreamUpgrade(req, new URL(req.url)); return this.handleNetworkLogStreamUpgrade(req, new URL(req.url));
@@ -1060,6 +1066,123 @@ export class OneboxHttpServer {
return response; 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 * Handle WebSocket upgrade for network access log streaming
*/ */

View File

@@ -93,6 +93,8 @@ export class PlatformServicesManager {
} }
// Check if already running // Check if already running
let needsDeploy = platformService.status !== 'running';
if (platformService.status === 'running') { if (platformService.status === 'running') {
// Verify it's actually healthy // Verify it's actually healthy
const isHealthy = await provider.healthCheck(); const isHealthy = await provider.healthCheck();
@@ -100,11 +102,14 @@ export class PlatformServicesManager {
logger.debug(`${provider.displayName} is already running and healthy`); logger.debug(`${provider.displayName} is already running and healthy`);
return platformService; 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 // Deploy if needed
if (platformService.status !== 'running') { if (needsDeploy) {
logger.info(`Starting ${provider.displayName} platform service...`); logger.info(`Starting ${provider.displayName} platform service...`);
try { try {
@@ -143,19 +148,28 @@ export class PlatformServicesManager {
*/ */
private async waitForHealthy(type: TPlatformServiceType, timeoutMs: number): Promise<boolean> { private async waitForHealthy(type: TPlatformServiceType, timeoutMs: number): Promise<boolean> {
const provider = this.providers.get(type); 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 startTime = Date.now();
const checkInterval = 2000; // Check every 2 seconds const checkInterval = 2000; // Check every 2 seconds
let checkCount = 0;
while (Date.now() - startTime < timeoutMs) { while (Date.now() - startTime < timeoutMs) {
checkCount++;
logger.info(`waitForHealthy: health check attempt #${checkCount} for ${type}`);
const isHealthy = await provider.healthCheck(); const isHealthy = await provider.healthCheck();
if (isHealthy) { if (isHealthy) {
logger.info(`waitForHealthy: ${type} became healthy after ${checkCount} attempts`);
return true; return true;
} }
await new Promise((resolve) => setTimeout(resolve, checkInterval)); await new Promise((resolve) => setTimeout(resolve, checkInterval));
} }
logger.warn(`waitForHealthy: ${type} did not become healthy after ${checkCount} attempts (${timeoutMs}ms)`);
return false; return false;
} }

View File

@@ -118,8 +118,19 @@ export class MinioProvider extends BasePlatformServiceProvider {
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
const containerName = this.getContainerName(); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
const endpoint = `http://${containerName}:9000/minio/health/live`; 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, { const response = await fetch(endpoint, {
method: 'GET', method: 'GET',

View File

@@ -52,21 +52,52 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/mongodb';
// Generate admin password
const adminPassword = credentialEncryption.generatePassword(32);
// Store admin credentials encrypted in the platform service record
const adminCredentials = {
username: 'admin',
password: adminPassword,
};
logger.info(`Deploying MongoDB platform service as ${containerName}...`); 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 // Ensure data directory exists
try { try {
await Deno.mkdir('/var/lib/onebox/mongodb', { recursive: true }); await Deno.mkdir(dataDir, { recursive: true });
} catch (e) { } catch (e) {
// Directory might already exist // Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) { if (!(e instanceof Deno.errors.AlreadyExists)) {
@@ -90,9 +121,8 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
network: this.getNetworkName(), network: this.getNetworkName(),
}); });
// Store encrypted admin credentials // Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) { if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, { this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId, containerId,
@@ -113,43 +143,59 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
logger.info('MongoDB health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); 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; return false;
} }
logger.info(`MongoDB health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Try to connect to MongoDB using mongosh ping // Use docker exec to run health check inside the container
const { MongoClient } = await import('npm:mongodb@6'); // This avoids network issues with overlay networks
const uri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; 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, { if (result.exitCode === 0) {
serverSelectionTimeoutMS: 5000, logger.info('MongoDB health check: success');
connectTimeoutMS: 5000, return true;
}); } else {
logger.info(`MongoDB health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
await client.connect(); return false;
await client.db('admin').command({ ping: 1 }); }
await client.close();
return true;
} catch (error) { } catch (error) {
logger.debug(`MongoDB health check failed: ${getErrorMessage(error)}`); logger.info(`MongoDB health check exception: ${getErrorMessage(error)}`);
return false; return false;
} }
} }
async provisionResource(userService: IService): Promise<IProvisionedResource> { async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); 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'); throw new Error('MongoDB platform service not found or not configured');
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); 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 // Generate resource names and credentials
const dbName = this.generateResourceName(userService.name); const dbName = this.generateResourceName(userService.name);
const username = 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}'...`); 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 { 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); const client = new MongoClient(adminUri);
await client.connect(); await client.connect();
@@ -211,17 +257,22 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> { async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); 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'); throw new Error('MongoDB platform service not found or not configured');
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); 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}'...`); logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
const { MongoClient } = await import('npm:mongodb@6'); 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); const client = new MongoClient(adminUri);
await client.connect(); await client.connect();

View File

@@ -117,6 +117,7 @@ export class LogStreamService {
} }
this.currentService = null; this.currentService = null;
this.isStreaming.set(false); this.isStreaming.set(false);
this.logs.set([]); // Clear logs when disconnecting to prevent stale logs showing on next service
this.state.set({ this.state.set({
connected: false, connected: false,
error: null, error: null,
@@ -137,4 +138,90 @@ export class LogStreamService {
getCurrentService(): string | null { getCurrentService(): string | null {
return this.currentService; 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);
}
}
} }

View File

@@ -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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
import { WebSocketService } from '../../core/services/websocket.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 { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types';
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component'; import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
import { import {
@@ -21,6 +23,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
standalone: true, standalone: true,
imports: [ imports: [
RouterLink, RouterLink,
FormsModule,
CardComponent, CardComponent,
CardHeaderComponent, CardHeaderComponent,
CardTitleComponent, CardTitleComponent,
@@ -157,21 +160,95 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>
</div> </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> </div>
`, `,
}) })
export class PlatformServiceDetailComponent implements OnInit { export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private api = inject(ApiService); private api = inject(ApiService);
private toast = inject(ToastService); private toast = inject(ToastService);
private ws = inject(WebSocketService); private ws = inject(WebSocketService);
logStream = inject(LogStreamService);
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
service = signal<IPlatformService | null>(null); service = signal<IPlatformService | null>(null);
stats = signal<IContainerStats | null>(null); stats = signal<IContainerStats | null>(null);
loading = signal(false); loading = signal(false);
actionLoading = signal(false); actionLoading = signal(false);
autoScroll = true;
private statsInterval: any; private statsInterval: any;
@@ -185,6 +262,16 @@ export class PlatformServiceDetailComponent implements OnInit {
this.stats.set(update.stats); 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 { ngOnInit(): void {
@@ -314,4 +401,26 @@ export class PlatformServiceDetailComponent implements OnInit {
this.actionLoading.set(false); 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();
}
} }