containers update + docker file build
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 3s

This commit is contained in:
Gal Podlipnik 2025-06-09 02:29:55 +02:00
parent 4a2a9eba30
commit dac64ac41a
6 changed files with 354 additions and 198 deletions

View File

@ -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",

View File

@ -1,5 +0,0 @@
import Docker from 'dockerode';
export function createDockerClient() {
return new Docker();
}

View File

@ -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<DockerContainer[]> {
})),
}));
} 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}`);
}
}

View File

@ -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);
}
}

View File

@ -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<WSContext<WebSocket>>();
const statsClients = new Set<WSContext<WebSocket>>();
@ -11,18 +15,29 @@ const combinedClients = new Set<WSContext<WebSocket>>();
const singleContainerClients = new Map<string, Set<WSContext<WebSocket>>>();
const activeStreams = new Map<string, () => void>();
const containerCache = new Map<
string,
{
data: DockerContainer | null;
lastUpdated: number;
}
>();
const CACHE_TTL = 2000;
const containerCache = new Map<string, {
data: DockerContainer | null;
lastUpdated: number;
}>();
const CACHE_TTL = 2000;
// Add a map to collect all container info
const allContainerInfo = new Map<string, ContainerInfo>();
let containerUpdateTimer: NodeJS.Timeout | null = null;
export function registerContainerClient(ws: WSContext<WebSocket>) {
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<WebSocket>) {
@ -37,7 +52,10 @@ export function registerCombinedClient(ws: WSContext<WebSocket>) {
if (wasEmpty) setupStreams();
}
export function registerSingleContainerClient(ws: WSContext<WebSocket>, containerId: string) {
export function registerSingleContainerClient(
ws: WSContext<WebSocket>,
containerId: string
) {
if (!singleContainerClients.has(containerId)) {
singleContainerClients.set(containerId, new Set());
}
@ -48,7 +66,6 @@ export function registerSingleContainerClient(ws: WSContext<WebSocket>, containe
}
}
export function removeContainerClient(ws: WSContext<WebSocket>) {
containerClients.delete(ws);
checkAndCleanup();
@ -64,7 +81,10 @@ export function removeCombinedClient(ws: WSContext<WebSocket>) {
checkAndCleanup();
}
export function removeSingleContainerClient(ws: WSContext<WebSocket>, containerId: string) {
export function removeSingleContainerClient(
ws: WSContext<WebSocket>,
containerId: string
) {
const clients = singleContainerClients.get(containerId);
if (clients) {
clients.delete(ws);
@ -76,10 +96,12 @@ export function removeSingleContainerClient(ws: WSContext<WebSocket>, 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<DockerContainer | null> {
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<DockerContainer | null> {
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;
}
}

View File

@ -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<WSContext<WebSocket>>, containerInfo: ContainerInfo) {
const payload = JSON.stringify({ containers: [containerInfo] });
export function sendContainerInfo(
containerClients: Set<WSContext<WebSocket>>,
containerInfo: ContainerInfo | ContainerInfo[]
) {
const containers = Array.isArray(containerInfo)
? containerInfo
: [containerInfo];
const payload = JSON.stringify({ containers });
sendToClientSet(containerClients, payload);
}
export function sendStats(statsClients: Set<WSContext<WebSocket>>, stats: ContainerStats) {
export function sendStats(
statsClients: Set<WSContext<WebSocket>>,
stats: ContainerStats
) {
const payload = JSON.stringify({ stats: [stats] });
sendToClientSet(statsClients, payload);
}
export function sendCombined(
combinedClients: Set<WSContext<WebSocket>>,
containerInfo: ContainerInfo,
combinedClients: Set<WSContext<WebSocket>>,
containerInfo: ContainerInfo,
stats: ContainerStats
) {
const payload = JSON.stringify({
containers: [containerInfo],
stats: [stats]
stats: [stats],
});
sendToClientSet(combinedClients, payload);
}
export function sendToSingleContainer(
singleContainerClients: Map<string, Set<WSContext<WebSocket>>>,
containerId: string,
containerInfo: ContainerInfo,
singleContainerClients: Map<string, Set<WSContext<WebSocket>>>,
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<WSContext<WebSocket>>, payload: string) {
export function sendToClientSet(
clients: Set<WSContext<WebSocket>>,
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 {