ui rebuild
This commit is contained in:
@@ -57,8 +57,8 @@ export class CloudflareDomainSync {
|
||||
try {
|
||||
logger.info('Starting Cloudflare zone synchronization...');
|
||||
|
||||
// Fetch all zones from Cloudflare
|
||||
const zones = await this.cloudflareAccount!.getZones();
|
||||
// Fetch all zones from Cloudflare (v6+ API uses convenience.listZones())
|
||||
const zones = await this.cloudflareAccount!.convenience.listZones();
|
||||
logger.info(`Found ${zones.length} Cloudflare zone(s)`);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -651,7 +651,13 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM services WHERE name = ?', [name]);
|
||||
return rows.length > 0 ? this.rowToService(rows[0]) : null;
|
||||
if (rows.length > 0) {
|
||||
logger.info(`getServiceByName: raw row data: ${JSON.stringify(rows[0])}`);
|
||||
const service = this.rowToService(rows[0]);
|
||||
logger.info(`getServiceByName: service object containerID: ${service.containerID}`);
|
||||
return service;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getServiceByID(id: number): IService | null {
|
||||
|
||||
@@ -40,8 +40,8 @@ export class OneboxDockerManager {
|
||||
*/
|
||||
private async ensureNetwork(): Promise<void> {
|
||||
try {
|
||||
const networks = await this.dockerClient!.getNetworks();
|
||||
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
|
||||
const networks = await this.dockerClient!.listNetworks();
|
||||
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
|
||||
|
||||
if (!existingNetwork) {
|
||||
logger.info(`Creating Docker network: ${this.networkName}`);
|
||||
@@ -228,14 +228,12 @@ export class OneboxDockerManager {
|
||||
* Get network ID by name
|
||||
*/
|
||||
private async getNetworkID(networkName: string): Promise<string> {
|
||||
const networks = await this.dockerClient!.getNetworks();
|
||||
const network = networks.find((n: any) =>
|
||||
(n.name || n.Name) === networkName
|
||||
);
|
||||
const networks = await this.dockerClient!.listNetworks();
|
||||
const network = networks.find((n: any) => n.Name === networkName);
|
||||
if (!network) {
|
||||
throw new Error(`Network not found: ${networkName}`);
|
||||
}
|
||||
return network.id || network.Id;
|
||||
return network.Id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,19 +576,32 @@ export class OneboxDockerManager {
|
||||
*/
|
||||
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
|
||||
try {
|
||||
const container = this.dockerClient!.getContainer(containerID);
|
||||
const container = await this.dockerClient!.getContainerById(containerID);
|
||||
|
||||
if (!container) {
|
||||
// Container not found - this is expected for Swarm services where we have service ID instead of container ID
|
||||
// Return null silently
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await container.stats({ stream: false });
|
||||
|
||||
// Validate stats structure
|
||||
if (!stats || !stats.cpu_stats || !stats.cpu_stats.cpu_usage) {
|
||||
logger.warn(`Invalid stats structure for container ${containerID}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate CPU percentage
|
||||
const cpuDelta =
|
||||
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage || 0);
|
||||
const cpuPercent =
|
||||
systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
|
||||
|
||||
// Memory stats
|
||||
const memoryUsed = stats.memory_stats.usage || 0;
|
||||
const memoryLimit = stats.memory_stats.limit || 0;
|
||||
const memoryUsed = stats.memory_stats?.usage || 0;
|
||||
const memoryLimit = stats.memory_stats?.limit || 0;
|
||||
const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0;
|
||||
|
||||
// Network stats
|
||||
@@ -612,49 +623,88 @@ export class OneboxDockerManager {
|
||||
networkTx,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get container stats ${containerID}: ${error.message}`);
|
||||
// Don't log errors for container not found - this is expected for Swarm services
|
||||
if (!error.message.includes('No such container') && !error.message.includes('not found')) {
|
||||
logger.error(`Failed to get container stats ${containerID}: ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get actual container ID for a Swarm service
|
||||
* For Swarm services, we need to find the task/container that's actually running
|
||||
*/
|
||||
private async getContainerIdForService(serviceId: string): Promise<string | null> {
|
||||
try {
|
||||
// List all containers and find one with the service label matching our service ID
|
||||
const containers = await this.dockerClient!.listContainers();
|
||||
|
||||
// Find a container that belongs to this service
|
||||
const serviceContainer = containers.find((container: any) => {
|
||||
const labels = container.Labels || {};
|
||||
// Swarm services have a com.docker.swarm.service.id label
|
||||
return labels['com.docker.swarm.service.id'] === serviceId;
|
||||
});
|
||||
|
||||
if (serviceContainer) {
|
||||
return serviceContainer.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get container ID for service ${serviceId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container logs
|
||||
* Handles both regular containers and Swarm services
|
||||
*/
|
||||
async getContainerLogs(
|
||||
containerID: string,
|
||||
tail = 100
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
const container = this.dockerClient!.getContainer(containerID);
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
timestamps: true,
|
||||
});
|
||||
let actualContainerId = containerID;
|
||||
|
||||
// Parse logs (Docker returns them in a special format)
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
// Try to get container directly first
|
||||
let container = await this.dockerClient!.getContainerById(containerID);
|
||||
|
||||
const lines = logs.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
|
||||
// Docker log format: first byte indicates stream (1=stdout, 2=stderr)
|
||||
const streamType = line.charCodeAt(0);
|
||||
const content = line.slice(8); // Skip header (8 bytes)
|
||||
|
||||
if (streamType === 1) {
|
||||
stdout.push(content);
|
||||
} else if (streamType === 2) {
|
||||
stderr.push(content);
|
||||
// If not found, it might be a service ID - try to get the actual container ID
|
||||
if (!container) {
|
||||
const serviceContainerId = await this.getContainerIdForService(containerID);
|
||||
if (serviceContainerId) {
|
||||
actualContainerId = serviceContainerId;
|
||||
container = await this.dockerClient!.getContainerById(serviceContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
throw new Error(`Container not found: ${containerID}`);
|
||||
}
|
||||
|
||||
// Get logs as string (v5 handles demultiplexing automatically)
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: tail,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// v5 should return a string, but let's handle edge cases
|
||||
if (typeof logs !== 'string') {
|
||||
logger.error(`Unexpected logs type: ${typeof logs}, constructor: ${logs?.constructor?.name}`);
|
||||
logger.error(`Logs content: ${JSON.stringify(logs).slice(0, 500)}`);
|
||||
// If it's not a string, something went wrong
|
||||
throw new Error(`Unexpected log format: expected string, got ${typeof logs}`);
|
||||
}
|
||||
|
||||
// v5 returns already-parsed logs as a string
|
||||
return {
|
||||
stdout: stdout.join('\n'),
|
||||
stderr: stderr.join('\n'),
|
||||
stdout: logs,
|
||||
stderr: '', // v5 combines stdout/stderr into single string
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get container logs ${containerID}: ${error.message}`);
|
||||
@@ -662,47 +712,15 @@ export class OneboxDockerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream container logs (real-time)
|
||||
*/
|
||||
async streamContainerLogs(
|
||||
containerID: string,
|
||||
callback: (line: string, isError: boolean) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const container = this.dockerClient!.getContainer(containerID);
|
||||
const stream = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: true,
|
||||
tail: 0,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
const streamType = chunk[0];
|
||||
const content = chunk.slice(8).toString();
|
||||
callback(content, streamType === 2);
|
||||
});
|
||||
|
||||
stream.on('error', (error: Error) => {
|
||||
logger.error(`Log stream error for ${containerID}: ${error.message}`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stream container logs ${containerID}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all onebox-managed containers
|
||||
*/
|
||||
async listContainers(): Promise<any[]> {
|
||||
try {
|
||||
const containers = await this.dockerClient!.getContainers();
|
||||
const containers = await this.dockerClient!.listContainers();
|
||||
// Filter for onebox-managed containers
|
||||
return containers.filter((c: any) =>
|
||||
c.labels && c.labels['managed-by'] === 'onebox'
|
||||
c.Labels && c.Labels['managed-by'] === 'onebox'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list containers: ${error.message}`);
|
||||
@@ -724,14 +742,16 @@ export class OneboxDockerManager {
|
||||
|
||||
/**
|
||||
* Get Docker version info
|
||||
* Note: v5 API doesn't expose version() method, so we return a placeholder
|
||||
*/
|
||||
async getDockerVersion(): Promise<any> {
|
||||
try {
|
||||
return await this.dockerClient!.version();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get Docker version: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
// v5 API doesn't have a version() method
|
||||
// Return a basic structure for compatibility
|
||||
return {
|
||||
Version: 'N/A',
|
||||
ApiVersion: 'N/A',
|
||||
Note: 'Version info not available in @apiclient.xyz/docker v5'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -753,7 +773,12 @@ export class OneboxDockerManager {
|
||||
*/
|
||||
async getContainerIP(containerID: string): Promise<string | null> {
|
||||
try {
|
||||
const container = this.dockerClient!.getContainer(containerID);
|
||||
const container = await this.dockerClient!.getContainerById(containerID);
|
||||
|
||||
if (!container) {
|
||||
throw new Error(`Container not found: ${containerID}`);
|
||||
}
|
||||
|
||||
const info = await container.inspect();
|
||||
|
||||
const networks = info.NetworkSettings.Networks;
|
||||
@@ -776,7 +801,11 @@ export class OneboxDockerManager {
|
||||
cmd: string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
try {
|
||||
const container = this.dockerClient!.getContainer(containerID);
|
||||
const container = await this.dockerClient!.getContainerById(containerID);
|
||||
|
||||
if (!container) {
|
||||
throw new Error(`Container not found: ${containerID}`);
|
||||
}
|
||||
|
||||
const exec = await container.exec({
|
||||
Cmd: cmd,
|
||||
|
||||
@@ -76,6 +76,12 @@ export class OneboxHttpServer {
|
||||
return this.handleWebSocketUpgrade(req);
|
||||
}
|
||||
|
||||
// Log streaming WebSocket
|
||||
if (path.startsWith('/api/services/') && path.endsWith('/logs/stream') && req.headers.get('upgrade') === 'websocket') {
|
||||
const serviceName = path.split('/')[3];
|
||||
return this.handleLogStreamUpgrade(req, serviceName);
|
||||
}
|
||||
|
||||
// Docker Registry v2 API (no auth required - registry handles it)
|
||||
if (path.startsWith('/v2/')) {
|
||||
return await this.oneboxRef.registry.handleRequest(req);
|
||||
@@ -107,25 +113,31 @@ export class OneboxHttpServer {
|
||||
filePath = '/index.html';
|
||||
}
|
||||
|
||||
const fullPath = `./ui/dist${filePath}`;
|
||||
const fullPath = `./ui/dist/ui/browser${filePath}`;
|
||||
|
||||
// Read file
|
||||
const file = await Deno.readFile(fullPath);
|
||||
|
||||
// Determine content type
|
||||
const contentType = this.getContentType(filePath);
|
||||
// Prevent stale bundles in dev (no hashed filenames) while allowing long-lived caching for hashed prod assets
|
||||
const isHashedAsset = /\.[a-f0-9]{8,}\./i.test(filePath);
|
||||
const cacheControl =
|
||||
filePath === '/index.html' || !isHashedAsset
|
||||
? 'no-cache'
|
||||
: 'public, max-age=31536000, immutable';
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': filePath === '/index.html' ? 'no-cache' : 'public, max-age=3600',
|
||||
'Cache-Control': cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// File not found - serve index.html for Angular routing
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
try {
|
||||
const indexFile = await Deno.readFile('./ui/dist/index.html');
|
||||
const indexFile = await Deno.readFile('./ui/dist/ui/browser/index.html');
|
||||
return new Response(indexFile, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
@@ -450,6 +462,8 @@ export class OneboxHttpServer {
|
||||
private async handleGetLogsRequest(name: string): Promise<Response> {
|
||||
try {
|
||||
const logs = await this.oneboxRef.services.getServiceLogs(name);
|
||||
logger.log(`handleGetLogsRequest: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`);
|
||||
logger.log(`handleGetLogsRequest: logs value = ${String(logs).slice(0, 100)}`);
|
||||
return this.jsonResponse({ success: true, data: logs });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
|
||||
@@ -824,6 +838,135 @@ export class OneboxHttpServer {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket upgrade for log streaming
|
||||
*/
|
||||
private handleLogStreamUpgrade(req: Request, serviceName: string): Response {
|
||||
const { socket, response } = Deno.upgradeWebSocket(req);
|
||||
|
||||
socket.onopen = async () => {
|
||||
logger.info(`Log stream WebSocket connected for service: ${serviceName}`);
|
||||
|
||||
try {
|
||||
// Get the service from database
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
socket.send(JSON.stringify({ error: 'Service not found' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the container (handle both direct container IDs and service IDs)
|
||||
logger.info(`Looking up container for service ${serviceName}, containerID: ${service.containerID}`);
|
||||
let container = await this.oneboxRef.docker.dockerClient!.getContainerById(service.containerID!);
|
||||
logger.info(`Direct lookup result: ${container ? 'found' : 'null'}`);
|
||||
|
||||
// If not found, it might be a service ID - try to get the actual container ID
|
||||
if (!container) {
|
||||
logger.info('Listing all containers to find matching service...');
|
||||
const containers = await this.oneboxRef.docker.dockerClient!.listContainers();
|
||||
logger.info(`Found ${containers.length} containers`);
|
||||
|
||||
const serviceContainer = containers.find((c: any) => {
|
||||
const labels = c.Labels || {};
|
||||
return labels['com.docker.swarm.service.id'] === service.containerID;
|
||||
});
|
||||
|
||||
if (serviceContainer) {
|
||||
logger.info(`Found matching container: ${serviceContainer.Id}`);
|
||||
container = await this.oneboxRef.docker.dockerClient!.getContainerById(serviceContainer.Id);
|
||||
logger.info(`Second lookup result: ${container ? 'found' : 'null'}`);
|
||||
} else {
|
||||
logger.error(`No container found with service label matching ${service.containerID}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
logger.error(`Container not found for service ${serviceName}, containerID: ${service.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: service.name,
|
||||
}));
|
||||
|
||||
// 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 = Buffer.alloc(0);
|
||||
|
||||
logStream.on('data', (chunk: Buffer) => {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Append new data to buffer
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
// Process complete frames
|
||||
while (buffer.length >= 8) {
|
||||
// Read frame size from header (bytes 4-7, big-endian)
|
||||
const frameSize = buffer.readUInt32BE(4);
|
||||
|
||||
// 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(frameData.toString('utf8'));
|
||||
|
||||
// Remove processed frame from buffer
|
||||
buffer = buffer.slice(8 + frameSize);
|
||||
}
|
||||
});
|
||||
|
||||
logStream.on('error', (error: Error) => {
|
||||
logger.error(`Log stream error for ${serviceName}: ${error.message}`);
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
|
||||
logStream.on('end', () => {
|
||||
logger.info(`Log stream ended for ${serviceName}`);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
// Clean up on close
|
||||
socket.onclose = () => {
|
||||
logger.info(`Log stream WebSocket closed for ${serviceName}`);
|
||||
logStream.destroy();
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start log stream for ${serviceName}: ${error.message}`);
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ error: error.message }));
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
logger.error(`Log stream WebSocket error: ${error}`);
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to all connected WebSocket clients
|
||||
*/
|
||||
|
||||
@@ -157,13 +157,22 @@ export class RegistryManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if getManifest method exists (API may have changed)
|
||||
if (typeof this.registry.getManifest !== 'function') {
|
||||
// Method not available in current API version
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifest = await this.registry.getManifest(repository, tag);
|
||||
if (manifest && manifest.digest) {
|
||||
return manifest.digest;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get digest for ${repository}:${tag}: ${error.message}`);
|
||||
// Only log if it's not a "not a function" error
|
||||
if (!error.message.includes('not a function')) {
|
||||
logger.warn(`Failed to get digest for ${repository}:${tag}: ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +333,13 @@ export class OneboxServicesManager {
|
||||
|
||||
const logs = await this.docker.getContainerLogs(service.containerID, tail);
|
||||
|
||||
return `=== STDOUT ===\n${logs.stdout}\n\n=== STDERR ===\n${logs.stderr}`;
|
||||
// Debug: check what we got
|
||||
logger.log(`getServiceLogs: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`);
|
||||
logger.log(`getServiceLogs: logs.stdout type = ${typeof logs.stdout}`);
|
||||
logger.log(`getServiceLogs: logs.stdout value = ${String(logs.stdout).slice(0, 100)}`);
|
||||
|
||||
// v5 API returns combined stdout/stderr with proper formatting
|
||||
return logs.stdout;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user