feat(platform-services): Add platform service log streaming, improve health checks and provisioning robustness
This commit is contained in:
12
changelog.md
12
changelog.md
@@ -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
8
ts/00_commitinfo_data.ts
Normal 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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user