From dac64ac41a5d6a72c18ba3cdef734b89e5b661ff Mon Sep 17 00:00:00 2001 From: Gal Podlipnik Date: Mon, 9 Jun 2025 02:29:55 +0200 Subject: [PATCH] containers update + docker file build --- backend/package.json | 3 +- backend/src/docker/client.ts | 5 - backend/src/docker/containers.ts | 49 +++--- backend/src/index.ts | 185 +++++++++++---------- backend/src/ws/manager.ts | 270 ++++++++++++++++++++++--------- backend/src/ws/sender.ts | 40 +++-- 6 files changed, 354 insertions(+), 198 deletions(-) delete mode 100644 backend/src/docker/client.ts diff --git a/backend/package.json b/backend/package.json index b9f965c..5859cd2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "build": "tsc" }, "dependencies": { "@hono/node-server": "^1.14.3", diff --git a/backend/src/docker/client.ts b/backend/src/docker/client.ts deleted file mode 100644 index 9b43e9d..0000000 --- a/backend/src/docker/client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Docker from 'dockerode'; - -export function createDockerClient() { - return new Docker(); -} diff --git a/backend/src/docker/containers.ts b/backend/src/docker/containers.ts index 3f699c2..0b063c7 100644 --- a/backend/src/docker/containers.ts +++ b/backend/src/docker/containers.ts @@ -1,5 +1,5 @@ -import { createDockerClient } from './client.js'; -import type { DockerContainer, Networks } from './types.js'; +import createDockerClient from "../utils/client.js"; +import type { DockerContainer, Networks } from "./types.js"; const docker = createDockerClient(); @@ -21,8 +21,8 @@ export async function listContainers(): Promise { })), })); } catch (error) { - console.error('Error listing containers:', error); - throw new Error('Failed to list Docker containers'); + console.error("Error listing containers:", error); + throw new Error("Failed to list Docker containers"); } } @@ -35,10 +35,13 @@ export async function getContainerStats(containerId: string) { const currSys = stats.cpu_stats.system_cpu_usage; const cpuDelta = currCpu - prevCpu; const sysDelta = currSys - prevSys; + const numCpus = + stats.cpu_stats.online_cpus || + (stats.cpu_stats.cpu_usage.percpu_usage + ? stats.cpu_stats.cpu_usage.percpu_usage.length + : 1); const cpuPercent = - sysDelta > 0 - ? (cpuDelta / sysDelta) * stats.cpu_stats.cpu_usage.percpu_usage.length * 100.0 - : 0.0; + sysDelta > 0 ? (cpuDelta / sysDelta) * numCpus * 100.0 : 0.0; const memUsage = stats.memory_stats.usage; const memLimit = stats.memory_stats.limit; const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100.0 : 0.0; @@ -58,8 +61,8 @@ export async function getContainerStats(containerId: string) { Array.isArray(stats.blkio_stats.io_service_bytes_recursive) ) { for (const entry of stats.blkio_stats.io_service_bytes_recursive) { - if (entry.op === 'Read') blkRead += entry.value; - if (entry.op === 'Write') blkWrite += entry.value; + if (entry.op === "Read") blkRead += entry.value; + if (entry.op === "Write") blkWrite += entry.value; } } return { @@ -82,19 +85,26 @@ export async function streamContainerStats( ): Promise<() => void> { const container = docker.getContainer(containerId); try { - const stream = (await container.stats({ stream: true })) as import('stream').Readable; - stream.on('data', (chunk: Buffer) => { + const stream = (await container.stats({ + stream: true, + })) as import("stream").Readable; + stream.on("data", (chunk: Buffer) => { const stats = JSON.parse(chunk.toString()); + const prevCpu = stats.precpu_stats.cpu_usage.total_usage; const prevSys = stats.precpu_stats.system_cpu_usage; const currCpu = stats.cpu_stats.cpu_usage.total_usage; const currSys = stats.cpu_stats.system_cpu_usage; const cpuDelta = currCpu - prevCpu; const sysDelta = currSys - prevSys; + + const numCpus = + stats.cpu_stats.online_cpus || + (stats.cpu_stats.cpu_usage.percpu_usage + ? stats.cpu_stats.cpu_usage.percpu_usage.length + : 1); const cpuPercent = - sysDelta > 0 - ? (cpuDelta / sysDelta) * stats.cpu_stats.cpu_usage.percpu_usage.length * 100.0 - : 0.0; + sysDelta > 0 ? (cpuDelta / sysDelta) * numCpus * 100.0 : 0.0; const memUsage = stats.memory_stats.usage; const memLimit = stats.memory_stats.limit; const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100.0 : 0.0; @@ -114,8 +124,8 @@ export async function streamContainerStats( Array.isArray(stats.blkio_stats.io_service_bytes_recursive) ) { for (const entry of stats.blkio_stats.io_service_bytes_recursive) { - if (entry.op === 'Read') blkRead += entry.value; - if (entry.op === 'Write') blkWrite += entry.value; + if (entry.op === "Read") blkRead += entry.value; + if (entry.op === "Write") blkWrite += entry.value; } } callback({ @@ -131,14 +141,17 @@ export async function streamContainerStats( timestamp: Date.now(), }); }); - stream.on('error', (err: Error) => { + stream.on("error", (err: Error) => { console.error(`Error streaming stats for container ${containerId}:`, err); }); return () => { stream.destroy(); }; } catch (error) { - console.error(`Error getting stats stream for container ${containerId}:`, error); + console.error( + `Error getting stats stream for container ${containerId}:`, + error + ); throw new Error(`Failed to get stats stream for container ${containerId}`); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index bd44546..7b5c8b3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,112 +1,133 @@ -import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { createNodeWebSocket } from '@hono/node-ws' +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { createNodeWebSocket } from "@hono/node-ws"; +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; import { - registerContainerClient, - registerStatsClient, registerCombinedClient, + registerContainerClient, registerSingleContainerClient, - removeContainerClient, - removeStatsClient, + registerStatsClient, removeCombinedClient, + removeContainerClient, removeSingleContainerClient, - stopAllStreams -} from './ws/manager.js'; + removeStatsClient, + stopAllStreams, +} from "./ws/manager.js"; const app = new Hono(); const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); -app.get('/', (c) => c.text('WS Docker Stats server (ESM) is running.')) +app.use("/*", serveStatic({ root: "./public" })); +app.get("/", (c) => c.text("WS Docker Stats server (ESM) is running.")); -// Containers-only endpoint -app.get('/ws/containers', upgradeWebSocket(c => ({ - onOpen(_, ws) { - console.log('WebSocket containers connection opened'); - registerContainerClient(ws); - }, - onClose(_, ws) { - console.log('❌ WS containers client disconnected'); - removeContainerClient(ws); - }, - onError(err) { - console.error('❌ WS containers error:', err); - } -}))) - -// Stats-only endpoint -app.get('/ws/stats', upgradeWebSocket(c => ({ - onOpen(_, ws) { - console.log('WebSocket stats connection opened'); - registerStatsClient(ws); - }, - onClose(_, ws) { - console.log('❌ WS stats client disconnected'); - removeStatsClient(ws); - }, - onError(err) { - console.error('❌ WS stats error:', err); - } -}))) - -// Combined endpoint (explicit) -app.get('/ws/combined', upgradeWebSocket(c => ({ - onOpen(_, ws) { - console.log('WebSocket combined connection opened'); - registerCombinedClient(ws); - }, - onClose(_, ws) { - console.log('❌ WS combined client disconnected'); - removeCombinedClient(ws); - }, - onError(err) { - console.error('❌ WS combined error:', err); - } -}))) - -// Single container endpoint -app.get('/ws/container', upgradeWebSocket(c => { - const containerId = c.req.query('id'); - if (!containerId) { - throw new Error('Container ID is required'); - } - return { +app.get( + "/ws/containers", + upgradeWebSocket((c) => ({ onOpen(_, ws) { - console.log(`WebSocket connection opened for container ${containerId}`); - registerSingleContainerClient(ws, containerId); + console.log("WebSocket containers connection opened"); + registerContainerClient(ws); }, onClose(_, ws) { - console.log(`❌ WS client for container ${containerId} disconnected`); - removeSingleContainerClient(ws, containerId); + console.log("❌ WS containers client disconnected"); + removeContainerClient(ws); }, onError(err) { - console.error(`❌ WS error for container ${containerId}:`, err); - } - }; -})) + console.error("❌ WS containers error:", err); + }, + })) +); -const server = serve({ - fetch: app.fetch, - port: 3000 -}, (info) => { - console.log(`Server is running on http://localhost:${info.port}`) -}) +// Stats-only endpoint +app.get( + "/ws/stats", + upgradeWebSocket((c) => ({ + onOpen(_, ws) { + console.log("WebSocket stats connection opened"); + registerStatsClient(ws); + }, + onClose(_, ws) { + console.log("❌ WS stats client disconnected"); + removeStatsClient(ws); + }, + onError(err) { + console.error("❌ WS stats error:", err); + }, + })) +); + +// Combined endpoint (explicit) +app.get( + "/ws/combined", + upgradeWebSocket((c) => ({ + onOpen(_, ws) { + console.log("WebSocket combined connection opened"); + registerCombinedClient(ws); + }, + onClose(_, ws) { + console.log("❌ WS combined client disconnected"); + removeCombinedClient(ws); + }, + onError(err) { + console.error("❌ WS combined error:", err); + }, + })) +); + +// Single container endpoint +app.get( + "/ws/container", + upgradeWebSocket((c) => { + const containerId = c.req.query("id"); + if (!containerId) { + throw new Error("Container ID is required"); + } + return { + onOpen(_, ws) { + console.log(`WebSocket connection opened for container ${containerId}`); + registerSingleContainerClient(ws, containerId); + }, + onClose(_, ws) { + console.log(`❌ WS client for container ${containerId} disconnected`); + removeSingleContainerClient(ws, containerId); + }, + onError(err) { + console.error(`❌ WS error for container ${containerId}:`, err); + }, + }; + }) +); + +app.get("*", (c) => { + return c.html(readFileSync("./public/index.html", "utf-8")); +}); + +const server = serve( + { + fetch: app.fetch, + port: 3000, + }, + (info) => { + console.log(`Server is running on http://localhost:${info.port}`); + } +); injectWebSocket(server); -process.on('SIGTERM', () => cleanupAndExit()); -process.on('SIGINT', () => cleanupAndExit()); +process.on("SIGTERM", () => cleanupAndExit()); +process.on("SIGINT", () => cleanupAndExit()); function cleanupAndExit() { - console.log('Shutting down gracefully...'); + console.log("Shutting down gracefully..."); stopAllStreams(); server.close(() => { - console.log('Server closed'); + console.log("Server closed"); process.exit(0); }); setTimeout(() => { - console.log('Forcing exit after timeout'); + console.log("Forcing exit after timeout"); process.exit(1); }, 5000); -} \ No newline at end of file +} diff --git a/backend/src/ws/manager.ts b/backend/src/ws/manager.ts index a351adb..0cee665 100644 --- a/backend/src/ws/manager.ts +++ b/backend/src/ws/manager.ts @@ -1,9 +1,13 @@ -import type { WSContext } from 'hono/ws'; -import { listContainers, streamContainerStats } from '../docker/containers.js'; -import { sendContainerInfo, sendStats, sendCombined, sendToSingleContainer } from './sender.js'; -import type { ContainerInfo, ContainerStats } from './types.js'; -import type { DockerContainer } from '../docker/types.js'; - +import type { WSContext } from "hono/ws"; +import { listContainers, streamContainerStats } from "../docker/containers.js"; +import type { DockerContainer } from "../docker/types.js"; +import { + sendCombined, + sendContainerInfo, + sendStats, + sendToSingleContainer, +} from "./sender.js"; +import type { ContainerInfo, ContainerStats } from "./types.js"; const containerClients = new Set>(); const statsClients = new Set>(); @@ -11,18 +15,29 @@ const combinedClients = new Set>(); const singleContainerClients = new Map>>(); const activeStreams = new Map void>(); +const containerCache = new Map< + string, + { + data: DockerContainer | null; + lastUpdated: number; + } +>(); +const CACHE_TTL = 2000; -const containerCache = new Map(); -const CACHE_TTL = 2000; - +// Add a map to collect all container info +const allContainerInfo = new Map(); +let containerUpdateTimer: NodeJS.Timeout | null = null; export function registerContainerClient(ws: WSContext) { const wasEmpty = allClientsEmpty(); containerClients.add(ws); if (wasEmpty) setupStreams(); + + // Send current container data immediately if available + if (allContainerInfo.size > 0) { + const containers = Array.from(allContainerInfo.values()); + sendContainerInfo(new Set([ws]), containers); + } } export function registerStatsClient(ws: WSContext) { @@ -37,7 +52,10 @@ export function registerCombinedClient(ws: WSContext) { if (wasEmpty) setupStreams(); } -export function registerSingleContainerClient(ws: WSContext, containerId: string) { +export function registerSingleContainerClient( + ws: WSContext, + containerId: string +) { if (!singleContainerClients.has(containerId)) { singleContainerClients.set(containerId, new Set()); } @@ -48,7 +66,6 @@ export function registerSingleContainerClient(ws: WSContext, containe } } - export function removeContainerClient(ws: WSContext) { containerClients.delete(ws); checkAndCleanup(); @@ -64,7 +81,10 @@ export function removeCombinedClient(ws: WSContext) { checkAndCleanup(); } -export function removeSingleContainerClient(ws: WSContext, containerId: string) { +export function removeSingleContainerClient( + ws: WSContext, + containerId: string +) { const clients = singleContainerClients.get(containerId); if (clients) { clients.delete(ws); @@ -76,10 +96,12 @@ export function removeSingleContainerClient(ws: WSContext, containerI } function allClientsEmpty() { - return containerClients.size === 0 && + return ( + containerClients.size === 0 && statsClients.size === 0 && combinedClients.size === 0 && - singleContainerClients.size === 0; + singleContainerClients.size === 0 + ); } function checkAndCleanup() { @@ -92,108 +114,193 @@ async function setupStreams() { if (allClientsEmpty()) return; try { const containers = await listContainers(); - + for (const container of containers) { + const containerInfo: ContainerInfo = { + id: container.id, + name: + container.names?.[0]?.replace("/", "") ?? container.id.slice(0, 12), + image: container.image, + state: container.state, + status: container.status, + }; + allContainerInfo.set(container.id, containerInfo); + containerCache.set(container.id, { data: container, - lastUpdated: Date.now() + lastUpdated: Date.now(), }); } - + + // Send all containers to clients + if (containerClients.size > 0) { + sendContainerInfo( + containerClients, + Array.from(allContainerInfo.values()) + ); + } + + // Setup periodic container list updates + if (!containerUpdateTimer) { + containerUpdateTimer = setInterval(updateAllContainers, 5000); + } + for (const container of containers) { if (activeStreams.has(container.id)) continue; setupStreamForContainer(container.id); } } catch (error) { - console.error('Error setting up streams:', error); + console.error("Error setting up streams:", error); } } -async function getContainerData(containerId: string): Promise { - const cached = containerCache.get(containerId); - const now = Date.now(); - - if (cached && (now - cached.lastUpdated < CACHE_TTL)) { - return cached.data; +async function updateAllContainers() { + if (allClientsEmpty()) { + if (containerUpdateTimer) { + clearInterval(containerUpdateTimer); + containerUpdateTimer = null; + } + return; } - + try { const containers = await listContainers(); - const containerData = containers.find(c => c.id === containerId) || null; - + + // Update container cache and allContainerInfo + const now = Date.now(); for (const container of containers) { containerCache.set(container.id, { data: container, - lastUpdated: now + lastUpdated: now, + }); + + const containerInfo: ContainerInfo = { + id: container.id, + name: + container.names?.[0]?.replace("/", "") ?? container.id.slice(0, 12), + image: container.image, + state: container.state, + status: container.status, + }; + allContainerInfo.set(container.id, containerInfo); + } + + // Send all containers to clients + if (containerClients.size > 0) { + sendContainerInfo( + containerClients, + Array.from(allContainerInfo.values()) + ); + } + } catch (error) { + console.error("Error updating containers:", error); + } +} + +async function getContainerData( + containerId: string +): Promise { + const cached = containerCache.get(containerId); + const now = Date.now(); + + if (cached && now - cached.lastUpdated < CACHE_TTL) { + return cached.data; + } + + try { + const containers = await listContainers(); + const containerData = containers.find((c) => c.id === containerId) || null; + + for (const container of containers) { + containerCache.set(container.id, { + data: container, + lastUpdated: now, }); } - + return containerData; } catch (error) { console.error(`Error fetching container data for ${containerId}:`, error); - + return cached?.data || null; } } async function setupStreamForContainer(containerId: string) { if (activeStreams.has(containerId)) return; - + try { const containerData = await getContainerData(containerId); if (!containerData) { console.warn(`Container ${containerId} not found`); return; } - - const stopStream = await streamContainerStats(containerId, async (stats: ContainerStats) => { - if (allClientsEmpty()) { - stopAllStreams(); - return; - } - - const freshContainerData = await getContainerData(containerId); - - const containerInfo: ContainerInfo = freshContainerData ? { - id: freshContainerData.id, - name: freshContainerData.names?.[0]?.replace('/', '') ?? freshContainerData.id.slice(0, 12), - image: freshContainerData.image, - state: freshContainerData.state, - status: freshContainerData.status, - } : { - id: containerData.id, - name: containerData.names?.[0]?.replace('/', '') ?? containerData.id.slice(0, 12), - image: containerData.image, - state: 'exited', - status: 'Container stopped', - }; + const stopStream = await streamContainerStats( + containerId, + async (stats: ContainerStats) => { + if (allClientsEmpty()) { + stopAllStreams(); + return; + } - - if (!freshContainerData && !singleContainerClients.has(containerId)) { - stopStreamForContainer(containerId); - return; - } + const freshContainerData = await getContainerData(containerId); - if (containerClients.size > 0) { - sendContainerInfo(containerClients, containerInfo); + const containerInfo: ContainerInfo = freshContainerData + ? { + id: freshContainerData.id, + name: + freshContainerData.names?.[0]?.replace("/", "") ?? + freshContainerData.id.slice(0, 12), + image: freshContainerData.image, + state: freshContainerData.state, + status: freshContainerData.status, + } + : { + id: containerData.id, + name: + containerData.names?.[0]?.replace("/", "") ?? + containerData.id.slice(0, 12), + image: containerData.image, + state: "exited", + status: "Container stopped", + }; + + // Update the containerInfo in our map + allContainerInfo.set(containerId, containerInfo); + + if (!freshContainerData && !singleContainerClients.has(containerId)) { + stopStreamForContainer(containerId); + return; + } + + // We don't send containerInfo here anymore, it's sent by updateAllContainers + + if (statsClients.size > 0) { + sendStats(statsClients, stats); + } + if (combinedClients.size > 0) { + sendCombined(combinedClients, containerInfo, stats); + } + const singleClients = singleContainerClients.get(containerId); + if (singleClients && singleClients.size > 0) { + sendToSingleContainer( + singleContainerClients, + containerId, + containerInfo, + stats + ); + } } - if (statsClients.size > 0) { - sendStats(statsClients, stats); - } - if (combinedClients.size > 0) { - sendCombined(combinedClients, containerInfo, stats); - } - const singleClients = singleContainerClients.get(containerId); - if (singleClients && singleClients.size > 0) { - sendToSingleContainer(singleContainerClients, containerId, containerInfo, stats); - } - }); - + ); + activeStreams.set(containerId, stopStream); } catch (error) { - console.error(`Failed to setup stream for container ${containerId}:`, error); - + console.error( + `Failed to setup stream for container ${containerId}:`, + error + ); + containerCache.delete(containerId); } } @@ -203,8 +310,9 @@ function stopStreamForContainer(containerId: string) { if (stopStream) { stopStream(); activeStreams.delete(containerId); - + containerCache.delete(containerId); + allContainerInfo.delete(containerId); } } @@ -214,4 +322,10 @@ export function stopAllStreams() { } activeStreams.clear(); containerCache.clear(); + allContainerInfo.clear(); + + if (containerUpdateTimer) { + clearInterval(containerUpdateTimer); + containerUpdateTimer = null; + } } diff --git a/backend/src/ws/sender.ts b/backend/src/ws/sender.ts index 11e0d25..4740534 100644 --- a/backend/src/ws/sender.ts +++ b/backend/src/ws/sender.ts @@ -1,37 +1,46 @@ -import type { WSContext } from 'hono/ws'; -import type { ContainerInfo, ContainerStats } from './types.js'; +import type { WSContext } from "hono/ws"; +import type { ContainerInfo, ContainerStats } from "./types.js"; -export function sendContainerInfo(containerClients: Set>, containerInfo: ContainerInfo) { - const payload = JSON.stringify({ containers: [containerInfo] }); +export function sendContainerInfo( + containerClients: Set>, + containerInfo: ContainerInfo | ContainerInfo[] +) { + const containers = Array.isArray(containerInfo) + ? containerInfo + : [containerInfo]; + const payload = JSON.stringify({ containers }); sendToClientSet(containerClients, payload); } -export function sendStats(statsClients: Set>, stats: ContainerStats) { +export function sendStats( + statsClients: Set>, + stats: ContainerStats +) { const payload = JSON.stringify({ stats: [stats] }); sendToClientSet(statsClients, payload); } export function sendCombined( - combinedClients: Set>, - containerInfo: ContainerInfo, + combinedClients: Set>, + containerInfo: ContainerInfo, stats: ContainerStats ) { const payload = JSON.stringify({ containers: [containerInfo], - stats: [stats] + stats: [stats], }); sendToClientSet(combinedClients, payload); } export function sendToSingleContainer( - singleContainerClients: Map>>, - containerId: string, - containerInfo: ContainerInfo, + singleContainerClients: Map>>, + containerId: string, + containerInfo: ContainerInfo, stats: ContainerStats ) { const combinedData = { ...containerInfo, - ...stats + ...stats, }; const payload = JSON.stringify({ container: combinedData }); const clients = singleContainerClients.get(containerId); @@ -40,13 +49,16 @@ export function sendToSingleContainer( } } -export function sendToClientSet(clients: Set>, payload: string) { +export function sendToClientSet( + clients: Set>, + payload: string +) { for (const ws of clients) { if (ws.readyState === 1) { try { ws.send(payload); } catch (err) { - console.error('Error sending to client:', err); + console.error("Error sending to client:", err); clients.delete(ws); } } else {