ui rebuild

This commit is contained in:
2025-11-24 19:52:35 +00:00
parent c9beae93c8
commit 9aa6906ca5
73 changed files with 8514 additions and 4537 deletions

View File

@@ -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
*/