diff --git a/docs/app.js b/docs/app.js index 9442ad0..c3e2814 100644 --- a/docs/app.js +++ b/docs/app.js @@ -1,6 +1,7 @@ let expandedContainer = null; // Only one expanded at a time let statIntervals = {}; // Track intervals for live updates per container let charts = {}; +let actionInProgress = false; // Prevent multiple simultaneous actions function getStatusColor(status) { if (status && status.includes('Up') && status.includes('healthy')) return 'bg-green-200 text-green-800'; @@ -13,6 +14,40 @@ function getStatusText(status) { return 'Stopped'; } +// Helper function to parse Docker stats +function parseDockerStats(stats) { + let cpu = 0, ram = 0, ramTotal = 1, rx = 0, tx = 0; + + // CPU calculation + if (stats.cpu_stats && stats.precpu_stats) { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const sysDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || 1; + if (cpuDelta > 0 && sysDelta > 0) { + cpu = ((cpuDelta / sysDelta) * cpuCount * 100); + } + } + + // Memory calculation + if (stats.memory_stats && stats.memory_stats.usage) { + ram = stats.memory_stats.usage / (1024 * 1024 * 1024); + ramTotal = stats.memory_stats.limit / (1024 * 1024 * 1024); + } + + // Network calculation + if (stats.networks) { + let totalRx = 0, totalTx = 0; + Object.values(stats.networks).forEach(nw => { + totalRx += nw.rx_bytes || 0; + totalTx += nw.tx_bytes || 0; + }); + rx = totalRx / 1024; + tx = totalTx / 1024; + } + + return { cpu, ram, ramTotal, rx, tx }; +} + function stopStatsUpdates(id) { if (statIntervals[id]) { clearInterval(statIntervals[id]); @@ -96,13 +131,20 @@ window.expandContainer = function(id) { }; window.containerAction = function(id, action) { + if (actionInProgress) { + return; // Prevent multiple simultaneous actions + } + actionInProgress = true; fetch(`/api/containers/${id}/${action}`, { method: 'POST' }) .then(res => res.json()) .then(() => { fetchStatsAndRender(); alert(`Action ${action} on container ${id} successful!`); }) - .catch(err => alert(`Failed to ${action}: ${err}`)); + .catch(err => alert(`Failed to ${action}: ${err}`)) + .finally(() => { + actionInProgress = false; + }); }; function startStatsUpdates(id) { @@ -111,74 +153,56 @@ function startStatsUpdates(id) { // history for chart let cpuH=[], ramH=[], netH=[]; async function pollStats() { - // poll stats - const res = await fetch(`/api/containers/${id}/stats`); - const stats = await res.json(); - // Parse stats (your Docker parse logic as before) - let cpu = 0, ram=0, ramTotal=1, rx=0, tx=0; try { - if (stats.cpu_stats && stats.precpu_stats) { - const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; - const sysDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; - const cpuCount = stats.cpu_stats.online_cpus || 1; - if (cpuDelta > 0 && sysDelta > 0) { - cpu = ((cpuDelta / sysDelta) * cpuCount * 100); - } - } - if (stats.memory_stats && stats.memory_stats.usage) { - ram = stats.memory_stats.usage / 1024 / 1024 / 1024; - ramTotal = stats.memory_stats.limit / 1024 / 1024 / 1024; - } - if (stats.networks) { - let totalRx = 0, totalTx = 0; - Object.values(stats.networks).forEach(nw => { - totalRx += nw.rx_bytes || 0; - totalTx += nw.tx_bytes || 0; - }); - rx = totalRx / 1024; - tx = totalTx / 1024; - } - } catch(e) {} + // poll stats + const res = await fetch(`/api/containers/${id}/stats`); + const stats = await res.json(); + + // Parse stats using helper function + const { cpu, ram, ramTotal, rx, tx } = parseDockerStats(stats); - // keep short history - cpuH.push(cpu); if (cpuH.length>15) cpuH.shift(); - ramH.push(ram); if (ramH.length>15) ramH.shift(); - netH.push(rx); if (netH.length>15) netH.shift(); + // keep short history + cpuH.push(cpu); if (cpuH.length>15) cpuH.shift(); + ramH.push(ram); if (ramH.length>15) ramH.shift(); + netH.push(rx); if (netH.length>15) netH.shift(); - // Update stats UI - const cpuEL = document.getElementById(`cpu-${id}`); - const ramEL = document.getElementById(`ram-${id}`); - const netEL = document.getElementById(`net-${id}`); - if(cpuEL) cpuEL.textContent = `${cpu.toFixed(1)}%`; - if(ramEL) ramEL.textContent = `${ram.toFixed(2)} / ${ramTotal.toFixed(2)} GB`; - if(netEL) netEL.textContent = `↑ ${rx.toFixed(1)} KB/s ↓ ${tx.toFixed(1)} KB/s`; + // Update stats UI + const cpuEL = document.getElementById(`cpu-${id}`); + const ramEL = document.getElementById(`ram-${id}`); + const netEL = document.getElementById(`net-${id}`); + if(cpuEL) cpuEL.textContent = `${cpu.toFixed(1)}%`; + if(ramEL) ramEL.textContent = `${ram.toFixed(2)} / ${ramTotal.toFixed(2)} GB`; + if(netEL) netEL.textContent = `↑ ${rx.toFixed(1)} KB/s ↓ ${tx.toFixed(1)} KB/s`; - // Render charts (handle repeated creation) - if(!charts[id]) { - charts[id] = { - cpu: new Chart(document.getElementById(`cpuChart-${id}`), { - type: 'line', - data: { labels:Array(cpuH.length).fill(''), datasets:[{label:"CPU",data:cpuH,borderColor:"#3b82f6",backgroundColor:"rgba(59,130,246,.11)",fill:true,tension:.45}] }, - options: {responsive:true, plugins:{legend:{display:false}}, scales:{x:{display:false},y:{display:false,min:0,max:100}}} - }), - ram: new Chart(document.getElementById(`ramChart-${id}`), { - type: 'line', - data: { labels:Array(ramH.length).fill(''), datasets:[{label:"RAM",data:ramH,borderColor:"#10b981",backgroundColor:"rgba(16,185,129,.15)",fill:true,tension:.45}] }, - options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} - }), - net: new Chart(document.getElementById(`netChart-${id}`), { - type: 'line', - data:{labels:Array(netH.length).fill(''),datasets:[{label:"NET",data:netH,borderColor:"#fde047",backgroundColor:"rgba(250,204,21,.09)",fill:true,tension:.45}]}, - options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} - }), - }; - } else { - charts[id].cpu.data.datasets[0].data = cpuH; - charts[id].cpu.update('none'); - charts[id].ram.data.datasets[0].data = ramH; - charts[id].ram.update('none'); - charts[id].net.data.datasets[0].data = netH; - charts[id].net.update('none'); + // Render charts (handle repeated creation) + if(!charts[id]) { + charts[id] = { + cpu: new Chart(document.getElementById(`cpuChart-${id}`), { + type: 'line', + data: { labels:Array(cpuH.length).fill(''), datasets:[{label:"CPU",data:cpuH,borderColor:"#3b82f6",backgroundColor:"rgba(59,130,246,.11)",fill:true,tension:.45}] }, + options: {responsive:true, plugins:{legend:{display:false}}, scales:{x:{display:false},y:{display:false,min:0,max:100}}} + }), + ram: new Chart(document.getElementById(`ramChart-${id}`), { + type: 'line', + data: { labels:Array(ramH.length).fill(''), datasets:[{label:"RAM",data:ramH,borderColor:"#10b981",backgroundColor:"rgba(16,185,129,.15)",fill:true,tension:.45}] }, + options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} + }), + net: new Chart(document.getElementById(`netChart-${id}`), { + type: 'line', + data:{labels:Array(netH.length).fill(''),datasets:[{label:"NET",data:netH,borderColor:"#fde047",backgroundColor:"rgba(250,204,21,.09)",fill:true,tension:.45}]}, + options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} + }), + }; + } else { + charts[id].cpu.data.datasets[0].data = cpuH; + charts[id].cpu.update('none'); + charts[id].ram.data.datasets[0].data = ramH; + charts[id].ram.update('none'); + charts[id].net.data.datasets[0].data = netH; + charts[id].net.update('none'); + } + } catch(e) { + console.error('Error polling stats:', e); } } pollStats(); // initial diff --git a/public/app.js b/public/app.js index 9442ad0..c3e2814 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,7 @@ let expandedContainer = null; // Only one expanded at a time let statIntervals = {}; // Track intervals for live updates per container let charts = {}; +let actionInProgress = false; // Prevent multiple simultaneous actions function getStatusColor(status) { if (status && status.includes('Up') && status.includes('healthy')) return 'bg-green-200 text-green-800'; @@ -13,6 +14,40 @@ function getStatusText(status) { return 'Stopped'; } +// Helper function to parse Docker stats +function parseDockerStats(stats) { + let cpu = 0, ram = 0, ramTotal = 1, rx = 0, tx = 0; + + // CPU calculation + if (stats.cpu_stats && stats.precpu_stats) { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const sysDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || 1; + if (cpuDelta > 0 && sysDelta > 0) { + cpu = ((cpuDelta / sysDelta) * cpuCount * 100); + } + } + + // Memory calculation + if (stats.memory_stats && stats.memory_stats.usage) { + ram = stats.memory_stats.usage / (1024 * 1024 * 1024); + ramTotal = stats.memory_stats.limit / (1024 * 1024 * 1024); + } + + // Network calculation + if (stats.networks) { + let totalRx = 0, totalTx = 0; + Object.values(stats.networks).forEach(nw => { + totalRx += nw.rx_bytes || 0; + totalTx += nw.tx_bytes || 0; + }); + rx = totalRx / 1024; + tx = totalTx / 1024; + } + + return { cpu, ram, ramTotal, rx, tx }; +} + function stopStatsUpdates(id) { if (statIntervals[id]) { clearInterval(statIntervals[id]); @@ -96,13 +131,20 @@ window.expandContainer = function(id) { }; window.containerAction = function(id, action) { + if (actionInProgress) { + return; // Prevent multiple simultaneous actions + } + actionInProgress = true; fetch(`/api/containers/${id}/${action}`, { method: 'POST' }) .then(res => res.json()) .then(() => { fetchStatsAndRender(); alert(`Action ${action} on container ${id} successful!`); }) - .catch(err => alert(`Failed to ${action}: ${err}`)); + .catch(err => alert(`Failed to ${action}: ${err}`)) + .finally(() => { + actionInProgress = false; + }); }; function startStatsUpdates(id) { @@ -111,74 +153,56 @@ function startStatsUpdates(id) { // history for chart let cpuH=[], ramH=[], netH=[]; async function pollStats() { - // poll stats - const res = await fetch(`/api/containers/${id}/stats`); - const stats = await res.json(); - // Parse stats (your Docker parse logic as before) - let cpu = 0, ram=0, ramTotal=1, rx=0, tx=0; try { - if (stats.cpu_stats && stats.precpu_stats) { - const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; - const sysDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; - const cpuCount = stats.cpu_stats.online_cpus || 1; - if (cpuDelta > 0 && sysDelta > 0) { - cpu = ((cpuDelta / sysDelta) * cpuCount * 100); - } - } - if (stats.memory_stats && stats.memory_stats.usage) { - ram = stats.memory_stats.usage / 1024 / 1024 / 1024; - ramTotal = stats.memory_stats.limit / 1024 / 1024 / 1024; - } - if (stats.networks) { - let totalRx = 0, totalTx = 0; - Object.values(stats.networks).forEach(nw => { - totalRx += nw.rx_bytes || 0; - totalTx += nw.tx_bytes || 0; - }); - rx = totalRx / 1024; - tx = totalTx / 1024; - } - } catch(e) {} + // poll stats + const res = await fetch(`/api/containers/${id}/stats`); + const stats = await res.json(); + + // Parse stats using helper function + const { cpu, ram, ramTotal, rx, tx } = parseDockerStats(stats); - // keep short history - cpuH.push(cpu); if (cpuH.length>15) cpuH.shift(); - ramH.push(ram); if (ramH.length>15) ramH.shift(); - netH.push(rx); if (netH.length>15) netH.shift(); + // keep short history + cpuH.push(cpu); if (cpuH.length>15) cpuH.shift(); + ramH.push(ram); if (ramH.length>15) ramH.shift(); + netH.push(rx); if (netH.length>15) netH.shift(); - // Update stats UI - const cpuEL = document.getElementById(`cpu-${id}`); - const ramEL = document.getElementById(`ram-${id}`); - const netEL = document.getElementById(`net-${id}`); - if(cpuEL) cpuEL.textContent = `${cpu.toFixed(1)}%`; - if(ramEL) ramEL.textContent = `${ram.toFixed(2)} / ${ramTotal.toFixed(2)} GB`; - if(netEL) netEL.textContent = `↑ ${rx.toFixed(1)} KB/s ↓ ${tx.toFixed(1)} KB/s`; + // Update stats UI + const cpuEL = document.getElementById(`cpu-${id}`); + const ramEL = document.getElementById(`ram-${id}`); + const netEL = document.getElementById(`net-${id}`); + if(cpuEL) cpuEL.textContent = `${cpu.toFixed(1)}%`; + if(ramEL) ramEL.textContent = `${ram.toFixed(2)} / ${ramTotal.toFixed(2)} GB`; + if(netEL) netEL.textContent = `↑ ${rx.toFixed(1)} KB/s ↓ ${tx.toFixed(1)} KB/s`; - // Render charts (handle repeated creation) - if(!charts[id]) { - charts[id] = { - cpu: new Chart(document.getElementById(`cpuChart-${id}`), { - type: 'line', - data: { labels:Array(cpuH.length).fill(''), datasets:[{label:"CPU",data:cpuH,borderColor:"#3b82f6",backgroundColor:"rgba(59,130,246,.11)",fill:true,tension:.45}] }, - options: {responsive:true, plugins:{legend:{display:false}}, scales:{x:{display:false},y:{display:false,min:0,max:100}}} - }), - ram: new Chart(document.getElementById(`ramChart-${id}`), { - type: 'line', - data: { labels:Array(ramH.length).fill(''), datasets:[{label:"RAM",data:ramH,borderColor:"#10b981",backgroundColor:"rgba(16,185,129,.15)",fill:true,tension:.45}] }, - options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} - }), - net: new Chart(document.getElementById(`netChart-${id}`), { - type: 'line', - data:{labels:Array(netH.length).fill(''),datasets:[{label:"NET",data:netH,borderColor:"#fde047",backgroundColor:"rgba(250,204,21,.09)",fill:true,tension:.45}]}, - options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} - }), - }; - } else { - charts[id].cpu.data.datasets[0].data = cpuH; - charts[id].cpu.update('none'); - charts[id].ram.data.datasets[0].data = ramH; - charts[id].ram.update('none'); - charts[id].net.data.datasets[0].data = netH; - charts[id].net.update('none'); + // Render charts (handle repeated creation) + if(!charts[id]) { + charts[id] = { + cpu: new Chart(document.getElementById(`cpuChart-${id}`), { + type: 'line', + data: { labels:Array(cpuH.length).fill(''), datasets:[{label:"CPU",data:cpuH,borderColor:"#3b82f6",backgroundColor:"rgba(59,130,246,.11)",fill:true,tension:.45}] }, + options: {responsive:true, plugins:{legend:{display:false}}, scales:{x:{display:false},y:{display:false,min:0,max:100}}} + }), + ram: new Chart(document.getElementById(`ramChart-${id}`), { + type: 'line', + data: { labels:Array(ramH.length).fill(''), datasets:[{label:"RAM",data:ramH,borderColor:"#10b981",backgroundColor:"rgba(16,185,129,.15)",fill:true,tension:.45}] }, + options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} + }), + net: new Chart(document.getElementById(`netChart-${id}`), { + type: 'line', + data:{labels:Array(netH.length).fill(''),datasets:[{label:"NET",data:netH,borderColor:"#fde047",backgroundColor:"rgba(250,204,21,.09)",fill:true,tension:.45}]}, + options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{display:false},y:{display:false,min:0}}} + }), + }; + } else { + charts[id].cpu.data.datasets[0].data = cpuH; + charts[id].cpu.update('none'); + charts[id].ram.data.datasets[0].data = ramH; + charts[id].ram.update('none'); + charts[id].net.data.datasets[0].data = netH; + charts[id].net.update('none'); + } + } catch(e) { + console.error('Error polling stats:', e); } } pollStats(); // initial diff --git a/server/docker/engineClient.js b/server/docker/engineClient.js index 09af2ab..5883252 100644 --- a/server/docker/engineClient.js +++ b/server/docker/engineClient.js @@ -12,9 +12,10 @@ function dockerRequest(path, method = 'GET') { timeout, }; const req = http.request(options, res => { - let data = ''; - res.on('data', chunk => data += chunk); + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); res.on('end', () => { + const data = Buffer.concat(chunks).toString(); try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } catch { diff --git a/server/docker/portainerClient.js b/server/docker/portainerClient.js index 454b46a..1eba6c3 100644 --- a/server/docker/portainerClient.js +++ b/server/docker/portainerClient.js @@ -21,9 +21,10 @@ function portainerRequest(path, method = 'GET') { timeout: 10000, }; const req = https.request(options, res => { - let data = ''; - res.on('data', chunk => data += chunk); + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); res.on('end', () => { + const data = Buffer.concat(chunks).toString(); try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } catch { diff --git a/server/docker/qbittorrentClient.js b/server/docker/qbittorrentClient.js index 0ae81ee..d8c5da4 100644 --- a/server/docker/qbittorrentClient.js +++ b/server/docker/qbittorrentClient.js @@ -7,6 +7,8 @@ class QBittorrentClient { this.username = username || 'admin'; this.password = password || 'adminadmin'; this.cookie = null; + this.loginAttempts = 0; + this.maxLoginAttempts = 2; } /** @@ -28,6 +30,7 @@ class QBittorrentClient { const cookies = response.headers['set-cookie']; if (cookies && cookies.length > 0) { this.cookie = cookies[0].split(';')[0]; + this.loginAttempts = 0; // Reset on successful login return true; } return false; @@ -72,12 +75,14 @@ class QBittorrentClient { }, }; } catch (error) { - // If unauthorized, try to login again - if (error.response && error.response.status === 403) { + // If unauthorized, try to login again (with retry limit) + if (error.response && error.response.status === 403 && this.loginAttempts < this.maxLoginAttempts) { this.cookie = null; + this.loginAttempts++; return this.getTransferInfo(); } + this.loginAttempts = 0; // Reset counter console.error('Failed to get qBittorrent transfer info:', error.message); return { success: false, @@ -109,11 +114,13 @@ class QBittorrentClient { data: response.data, }; } catch (error) { - if (error.response && error.response.status === 403) { + if (error.response && error.response.status === 403 && this.loginAttempts < this.maxLoginAttempts) { this.cookie = null; + this.loginAttempts++; return this.getTorrents(); } + this.loginAttempts = 0; // Reset counter console.error('Failed to get qBittorrent torrents:', error.message); return { success: false, diff --git a/server/index.js b/server/index.js index 94fcf3a..a233b33 100644 --- a/server/index.js +++ b/server/index.js @@ -13,6 +13,7 @@ const authRoute = require('./routes/auth'); const { requireAuth, isAuthEnabled } = require('./middleware/auth'); const wsHandlers = require('./sockets'); const path = require('path'); +const crypto = require('crypto'); const config = require('./config/defaults'); const { validateEnv } = require('./config/schema'); try { @@ -72,7 +73,7 @@ app.use('/api/', apiLimiter); // Basic request logging with request ID app.use((req, res, next) => { - req.id = require('crypto').randomUUID(); + req.id = crypto.randomUUID(); res.setHeader('X-Request-ID', req.id); console.log(`[${req.id}] [${new Date().toISOString()}] ${req.method} ${req.url}`); next(); diff --git a/server/routes/containers.js b/server/routes/containers.js index e585197..1b4a668 100644 --- a/server/routes/containers.js +++ b/server/routes/containers.js @@ -1,25 +1,22 @@ const express = require('express'); const router = express.Router(); const proxy = require('../docker/proxy'); +const { calculateCPUPercent } = require('../utils/cpu'); const Docker = require('dockerode'); const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' }); // --- METRICS HISTORY SUPPORT --- const METRICS_WINDOW = 300; // e.g. 300 points = 10 minutes if polled every 2s +const BYTES_TO_GB = 1024 * 1024 * 1024; // Constant for byte to GB conversion const metricsHistory = {}; // { [containerId]: [{ time, cpu, ram, rx, tx, disk, ramTotal }, ...] } function saveContainerMetrics(id, stats, info) { let cpu=0, ram=0, rx=0, tx=0, disk=0, ramTotal=1; try { - if(stats.cpu_stats && stats.precpu_stats) { - const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; - const sysDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; - const cpuCount = stats.cpu_stats.online_cpus||1; - if(cpuDelta > 0 && sysDelta > 0) cpu = ((cpuDelta/sysDelta)*cpuCount*100); - } + cpu = calculateCPUPercent(stats); if(stats.memory_stats && stats.memory_stats.usage) { - ram = stats.memory_stats.usage / 1024 / 1024 / 1024; - ramTotal = stats.memory_stats.limit / 1024 / 1024 / 1024; + ram = stats.memory_stats.usage / BYTES_TO_GB; + ramTotal = stats.memory_stats.limit / BYTES_TO_GB; } if(stats.networks) { for (const nw of Object.values(stats.networks)) { @@ -27,7 +24,7 @@ function saveContainerMetrics(id, stats, info) { tx += nw.tx_bytes || 0; } } - if(info && info.SizeRw) disk = info.SizeRw / 1024 / 1024 / 1024; + if(info && info.SizeRw) disk = info.SizeRw / BYTES_TO_GB; } catch(e) {} if(!metricsHistory[id]) metricsHistory[id] = []; metricsHistory[id].push({ diff --git a/server/sockets/logs.js b/server/sockets/logs.js index 52aac6a..efaa33e 100644 --- a/server/sockets/logs.js +++ b/server/sockets/logs.js @@ -4,7 +4,16 @@ const engine = require('../docker/engineClient'); function streamLogs(ws, id) { let closed = false; let reqHandle = null; - ws.on('close', () => { closed = true; if (reqHandle && reqHandle.abort) try { reqHandle.abort(); } catch (e) {} }); + ws.on('close', () => { + closed = true; + if (reqHandle && reqHandle.abort) { + try { + reqHandle.abort(); + } catch (e) { + // Ignore abort errors + } + } + }); // Try using engineClient streaming which forwards chunks as they arrive try { @@ -14,17 +23,43 @@ function streamLogs(ws, id) { try { // chunk may be a Buffer; convert and split into lines const text = chunk.toString('utf8'); - text.split('\n').forEach(line => { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; if (line && line.trim()) { - try { ws.send(JSON.stringify({ log: line })); } catch (e) {} + try { + ws.send(JSON.stringify({ log: line })); + } catch (sendErr) { + // Connection closed, ignore + } } - }); + } } catch (e) { - try { ws.send(JSON.stringify({ error: 'Failed to process log chunk' })); } catch (e) {} + try { + ws.send(JSON.stringify({ error: 'Failed to process log chunk' })); + } catch (sendErr) { + // Connection closed, ignore + } + } + }, + onEnd: () => { + if (!closed) { + try { + ws.send(JSON.stringify({ info: 'log stream ended' })); + } catch (e) { + // Ignore send errors + } } }, - onEnd: () => { if (!closed) try { ws.send(JSON.stringify({ info: 'log stream ended' })); } catch (e) {} }, - onError: (err) => { if (!closed) try { ws.send(JSON.stringify({ error: String(err && err.message ? err.message : err) })); } catch (e) {} } + onError: (err) => { + if (!closed) { + try { + ws.send(JSON.stringify({ error: String(err && err.message ? err.message : err) })); + } catch (e) { + // Ignore send errors + } + } + } }); } catch (err) { // Fallback: try a single request via proxy (non-follow) @@ -32,12 +67,34 @@ function streamLogs(ws, id) { try { const result = await proxy.requestDockerAPI(`/containers/${id}/logs?stdout=1&stderr=1&tail=200`); if (typeof result.data === 'string') { - result.data.split('\n').forEach(line => { if (!closed && line.trim()) ws.send(JSON.stringify({ log: line })); }); + const lines = result.data.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!closed && line.trim()) { + try { + ws.send(JSON.stringify({ log: line })); + } catch (sendErr) { + // Connection closed, ignore + } + } + } } else { - if (!closed) ws.send(JSON.stringify({ log: result.data })); + if (!closed) { + try { + ws.send(JSON.stringify({ log: result.data })); + } catch (sendErr) { + // Connection closed, ignore + } + } } } catch (err2) { - if (!closed) ws.send(JSON.stringify({ error: err2.message })); + if (!closed) { + try { + ws.send(JSON.stringify({ error: err2.message })); + } catch (sendErr) { + // Connection closed, ignore + } + } } })(); }