Files
ContainerFlow/src/server/watcher.ts
T
2026-05-11 00:30:55 +00:00

155 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { docker } from "./docker";
import type { Service, Stats, DockerEvent } from "../shared/types";
/** Compute real memory usage by subtracting reclaimable page cache.
* Works for cgroup v1 and v2.
*
* Why: memory_stats.usage includes the kernel page cache — file-backed pages
* the kernel keeps in RAM after reading from disk. Both `active_file` and
* `inactive_file` are reclaimable under memory pressure (the kernel drops
* inactive first, then active when needed). They are NOT real container usage.
*
* Note: `docker stats` CLI only subtracts `inactive_file`, which leaves the
* `active_file` portion looking like real usage. For DB containers (Postgres,
* MySQL, Mongo) most of their cached working set lives in `active_file`, so
* `docker stats` still over-reports. We subtract the full file cache for a
* more honest reading. The breakdown is exposed in mem_breakdown for users
* who want to see what's cache vs anon vs total.
*
* Returns: { real, cache, anon, total, limit } all in bytes. */
export function computeMemoryBreakdown(memoryStats: any): {
real: number;
cache: number;
anon: number;
total: number;
limit: number;
} {
const total = memoryStats?.usage ?? 0;
const limit = memoryStats?.limit ?? 0;
const s = memoryStats?.stats ?? {};
// Total reclaimable file-backed page cache (does NOT include shmem, which is
// shared memory like Postgres shared_buffers — that IS real usage).
// Prefer `active_file + inactive_file` (cgroup v2) over `file` because some
// kernels include shmem in `file`, which would over-subtract.
let cache = 0;
if (typeof s.active_file === "number" || typeof s.inactive_file === "number") {
// cgroup v2 — sum the two file-cache buckets
cache = (s.active_file ?? 0) + (s.inactive_file ?? 0);
} else if (typeof s.total_cache === "number") {
// cgroup v1
cache = s.total_cache;
} else if (typeof s.cache === "number") {
// cgroup v1 (older)
cache = s.cache;
} else if (typeof s.total_inactive_file === "number") {
cache = s.total_inactive_file;
} else if (typeof s.file === "number") {
// Last-resort fallback (some kernels)
cache = s.file;
}
// anon = process memory (heap, stack). cgroup v2: `anon`. cgroup v1: `rss` or `total_rss`.
const anon = s.anon ?? s.total_rss ?? s.rss ?? 0;
const real = Math.max(0, total - cache);
return { real, cache, anon, total, limit };
}
export async function pollStats(services: Service[]): Promise<Stats[]> {
const running = services.filter((s) => s.state === "running");
// Poll all containers in parallel — sequential polling makes the first cycle
// take ~3s × N containers, blocking the dashboard on page load. The Docker
// daemon handles concurrent stats requests fine.
const results = await Promise.all(running.map(async (svc): Promise<Stats | null> => {
try {
const container = docker.getContainer(svc.id);
const raw = await Promise.race([
container.stats({ stream: false }),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)),
]) as any;
const cpuDelta =
raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
const sysDelta =
raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
const onlineCpus = raw.cpu_stats.online_cpus || 1;
const cpuHost =
sysDelta > 0
? (cpuDelta / sysDelta) * onlineCpus * 100
: 0;
const cpu = svc.cpu_quota > 0
? (cpuHost * 100000 / svc.cpu_quota)
: cpuHost;
const mb = computeMemoryBreakdown(raw.memory_stats);
const memLimit = mb.limit || 1;
const TO_MB = 1024 * 1024;
return {
service: svc.uid,
cpu: parseFloat(cpu.toFixed(2)),
mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)),
mem_percent: parseFloat(((mb.real / memLimit) * 100).toFixed(1)),
mem_breakdown: {
anon_mb: parseFloat((mb.anon / TO_MB).toFixed(1)),
cache_mb: parseFloat((mb.cache / TO_MB).toFixed(1)),
total_mb: parseFloat((mb.total / TO_MB).toFixed(1)),
limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)),
},
};
} catch {
// Container may have stopped between discovery and stats, or stats timed out
return null;
}
}));
return results.filter((r): r is Stats => r !== null);
}
export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
docker.getEvents({}, (err, stream) => {
if (err || !stream) {
console.error("Failed to watch Docker events:", err);
return;
}
let buffer = "";
stream.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // keep incomplete last line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const event = JSON.parse(trimmed);
if (event.Type !== "container") continue;
const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
if (!["start", "stop", "die", "restart", "destroy", "create", "health_status"].includes(action)) continue;
const svcName =
event.Actor?.Attributes?.["com.docker.compose.service"] ||
event.Actor?.Attributes?.name ||
"unknown";
const svcProject =
event.Actor?.Attributes?.["com.docker.compose.project"] ||
"standalone";
onEvent({
type: "docker",
action,
service: `${svcProject}/${svcName}`,
time: event.time || Date.now() / 1000,
});
} catch {
// Ignore malformed lines
}
}
});
});
}