mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
import Docker from "dockerode";
|
|
import fs from "fs";
|
|
import type { Service, Connection, LogLine } from "../shared/types";
|
|
|
|
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
|
|
|
|
const isTest = process.env.NODE_ENV === "test" || !!process.env.VITEST;
|
|
|
|
if (!isTest && !fs.existsSync(DOCKER_SOCKET)) {
|
|
console.error(`\n Error: Docker socket not found at ${DOCKER_SOCKET}`);
|
|
console.error(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const docker = new Docker({ socketPath: DOCKER_SOCKET });
|
|
|
|
export { docker };
|
|
|
|
export async function discoverServices(all: boolean, projects: string[]): Promise<Service[]> {
|
|
const containers = await docker.listContainers({ all: true });
|
|
|
|
// Inspect containers in parallel to get detailed info
|
|
const inspections = await Promise.all(
|
|
containers.map((c) =>
|
|
docker.getContainer(c.Id).inspect().catch(() => null)
|
|
)
|
|
);
|
|
|
|
let services: Service[] = containers.map((c, i) => {
|
|
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
|
|
const project = c.Labels["com.docker.compose.project"] || "standalone";
|
|
const info = inspections[i] as any;
|
|
|
|
// Extract network IPs
|
|
const networkIps: Record<string, string> = {};
|
|
const nets = info?.NetworkSettings?.Networks || {};
|
|
for (const [netName, netInfo] of Object.entries(nets)) {
|
|
const ip = (netInfo as any)?.IPAddress;
|
|
if (ip) networkIps[netName] = ip;
|
|
}
|
|
|
|
// Health check
|
|
const healthState = info?.State?.Health;
|
|
const healthStatus = healthState?.Status || "";
|
|
const healthLog = (healthState?.Log || [])
|
|
.slice(-5)
|
|
.map((entry: any) => `[${entry.ExitCode}] ${entry.Output?.trim() || ""}`)
|
|
.filter((s: string) => s.length > 4);
|
|
|
|
const exitCode: number = info?.State?.ExitCode ?? 0;
|
|
const restartCount: number = info?.RestartCount ?? 0;
|
|
const oomKilled: boolean = info?.State?.OOMKilled ?? false;
|
|
|
|
// Detect crashed: exited with non-zero exit code or OOM killed
|
|
const rawState = c.State as string;
|
|
const isCrashed = rawState === "exited" && (exitCode !== 0 || oomKilled);
|
|
const state: Service["state"] = isCrashed ? "crashed" : rawState as Service["state"];
|
|
|
|
return {
|
|
id: c.Id.slice(0, 12),
|
|
uid: `${project}/${name}`,
|
|
name,
|
|
image: c.Image,
|
|
state,
|
|
status: c.Status,
|
|
ports: [...new Map(
|
|
c.Ports.filter((p) => p.PublicPort).map((p) => [
|
|
`${p.PublicPort}:${p.PrivatePort}`,
|
|
{ host: p.PublicPort!, container: p.PrivatePort },
|
|
])
|
|
).values()],
|
|
networks: Object.keys(c.NetworkSettings?.Networks || {}),
|
|
network_ips: networkIps,
|
|
project,
|
|
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
|
|
env: (info?.Config?.Env || []) as string[],
|
|
restart_policy: info?.HostConfig?.RestartPolicy?.Name || "",
|
|
memory_limit: info?.HostConfig?.Memory || 0,
|
|
cpu_quota: info?.HostConfig?.CpuQuota || 0,
|
|
health_status: healthStatus,
|
|
health_log: healthLog,
|
|
exit_code: exitCode,
|
|
restart_count: restartCount,
|
|
oom_killed: oomKilled,
|
|
};
|
|
});
|
|
|
|
if (!all && projects.length > 0) {
|
|
services = services.filter((s) => projects.includes(s.project));
|
|
}
|
|
|
|
return services;
|
|
}
|
|
|
|
// ── Infrastructure service detection ──
|
|
// These are services that OTHER services connect TO (databases, caches, brokers, proxies)
|
|
|
|
const INFRA_PATTERNS: { pattern: string; type: string; label: string; role: "target" }[] = [
|
|
{ pattern: "postgres", type: "database", label: "postgres", role: "target" },
|
|
{ pattern: "mysql", type: "database", label: "mysql", role: "target" },
|
|
{ pattern: "mariadb", type: "database", label: "mariadb", role: "target" },
|
|
{ pattern: "mongo", type: "database", label: "mongo", role: "target" },
|
|
{ pattern: "redis", type: "cache", label: "redis", role: "target" },
|
|
{ pattern: "memcached", type: "cache", label: "memcached", role: "target" },
|
|
{ pattern: "rabbitmq", type: "broker", label: "rabbitmq", role: "target" },
|
|
{ pattern: "kafka", type: "broker", label: "kafka", role: "target" },
|
|
{ pattern: "nats", type: "broker", label: "nats", role: "target" },
|
|
];
|
|
|
|
const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"];
|
|
|
|
export function isInfraService(svc: Service): { type: string; label: string } | null {
|
|
const img = svc.image.toLowerCase();
|
|
const name = svc.name.toLowerCase();
|
|
for (const p of INFRA_PATTERNS) {
|
|
if (img.includes(p.pattern) || name.includes(p.pattern)) {
|
|
return { type: p.type, label: p.label };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function isProxyService(svc: Service): boolean {
|
|
const img = svc.image.toLowerCase();
|
|
const name = svc.name.toLowerCase();
|
|
return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p));
|
|
}
|
|
|
|
export function isWorkerService(svc: Service): boolean {
|
|
const name = svc.name.toLowerCase();
|
|
return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron");
|
|
}
|
|
|
|
export async function discoverConnections(services: Service[]): Promise<Connection[]> {
|
|
const connections: Connection[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
// Separate services by role
|
|
const infraServices = services.filter((s) => isInfraService(s));
|
|
const proxyServices = services.filter((s) => isProxyService(s));
|
|
const workerServices = services.filter((s) => isWorkerService(s));
|
|
const appServices = services.filter(
|
|
(s) => !isInfraService(s) && !isProxyService(s) && !isWorkerService(s)
|
|
);
|
|
|
|
function addConnection(from: string, to: string, type: string, label: string) {
|
|
const key = `${from}:${to}`;
|
|
if (seen.has(key) || from === to) return;
|
|
seen.add(key);
|
|
connections.push({ from, to, network: "", type, label });
|
|
}
|
|
|
|
// 1. App services → infra services (backend→db, backend→redis, etc.)
|
|
for (const app of appServices) {
|
|
// Each app connects to DB and cache in the same network
|
|
for (const infra of infraServices) {
|
|
const hasSharedNetwork = app.networks.some((n) => infra.networks.includes(n));
|
|
if (!hasSharedNetwork) continue;
|
|
const edge = isInfraService(infra)!;
|
|
addConnection(app.uid, infra.uid, edge.type, edge.label);
|
|
}
|
|
}
|
|
|
|
// 2. Workers → infra (celery→redis as broker, celery→db)
|
|
for (const worker of workerServices) {
|
|
for (const infra of infraServices) {
|
|
const hasSharedNetwork = worker.networks.some((n) => infra.networks.includes(n));
|
|
if (!hasSharedNetwork) continue;
|
|
const edge = isInfraService(infra)!;
|
|
const label = edge.type === "cache" ? "broker" : edge.label;
|
|
addConnection(worker.uid, infra.uid, edge.type, label);
|
|
}
|
|
}
|
|
|
|
// 3. Proxy → app services (nginx→backend, nginx→frontend, nginx→auth)
|
|
for (const proxy of proxyServices) {
|
|
for (const app of appServices) {
|
|
const hasSharedNetwork = proxy.networks.some((n) => app.networks.includes(n));
|
|
if (!hasSharedNetwork) continue;
|
|
addConnection(proxy.uid, app.uid, "proxy", "upstream");
|
|
}
|
|
}
|
|
|
|
// 4. Collector → infra (special: collector writes to db and redis)
|
|
for (const svc of services) {
|
|
if (svc.name.toLowerCase().includes("collector")) {
|
|
for (const infra of infraServices) {
|
|
const hasSharedNetwork = svc.networks.some((n) => infra.networks.includes(n));
|
|
if (!hasSharedNetwork) continue;
|
|
const edge = isInfraService(infra)!;
|
|
addConnection(svc.uid, infra.uid, edge.type, edge.label);
|
|
}
|
|
}
|
|
}
|
|
|
|
return connections;
|
|
}
|
|
|
|
// ── Container logs ──
|
|
|
|
export async function getContainerLogs(id: string, tail = 200, since?: number): Promise<LogLine[]> {
|
|
const container = docker.getContainer(id);
|
|
const opts: Record<string, any> = {
|
|
stdout: true,
|
|
stderr: true,
|
|
timestamps: true,
|
|
};
|
|
if (since) {
|
|
// When filtering by time, use since only (no tail limit) — Docker applies tail before since
|
|
opts.since = since;
|
|
} else {
|
|
opts.tail = tail;
|
|
}
|
|
const logBuffer = await container.logs(opts);
|
|
|
|
const lines: LogLine[] = [];
|
|
const raw = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer as any);
|
|
|
|
let offset = 0;
|
|
while (offset < raw.length) {
|
|
if (offset + 8 > raw.length) break;
|
|
const streamType = raw[offset];
|
|
const size = raw.readUInt32BE(offset + 4);
|
|
if (offset + 8 + size > raw.length) break;
|
|
|
|
const payload = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd();
|
|
offset += 8 + size;
|
|
|
|
if (!payload) continue;
|
|
|
|
// Timestamp is at the start: "2024-01-01T00:00:00.000000000Z rest of line"
|
|
const spaceIdx = payload.indexOf(" ");
|
|
const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : "";
|
|
const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload;
|
|
|
|
lines.push({
|
|
container: id,
|
|
line,
|
|
timestamp,
|
|
stream: streamType === 2 ? "stderr" : "stdout",
|
|
});
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
export function streamContainerLogs(
|
|
id: string,
|
|
onLine: (line: LogLine) => void,
|
|
): { destroy: () => void } {
|
|
const container = docker.getContainer(id);
|
|
let stream: NodeJS.ReadableStream | null = null;
|
|
let destroyed = false;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const destroyStream = (s: unknown) => {
|
|
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
|
|
};
|
|
|
|
function connect() {
|
|
if (destroyed) return;
|
|
|
|
container.logs({
|
|
stdout: true,
|
|
stderr: true,
|
|
follow: true,
|
|
since: Math.floor(Date.now() / 1000),
|
|
timestamps: true,
|
|
}).then((s) => {
|
|
stream = s as unknown as NodeJS.ReadableStream;
|
|
|
|
if (destroyed) {
|
|
destroyStream(stream);
|
|
stream = null;
|
|
return;
|
|
}
|
|
|
|
// Docker multiplexed stream parsing for follow mode
|
|
let buffer = Buffer.alloc(0);
|
|
|
|
stream.on("data", (chunk: Buffer) => {
|
|
if (destroyed) return;
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
|
|
while (buffer.length >= 8) {
|
|
const streamType = buffer[0];
|
|
const size = buffer.readUInt32BE(4);
|
|
if (buffer.length < 8 + size) break;
|
|
|
|
const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd();
|
|
buffer = buffer.slice(8 + size);
|
|
|
|
if (!payload) continue;
|
|
|
|
const spaceIdx = payload.indexOf(" ");
|
|
const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : "";
|
|
const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload;
|
|
|
|
onLine({
|
|
container: id,
|
|
line,
|
|
timestamp,
|
|
stream: streamType === 2 ? "stderr" : "stdout",
|
|
});
|
|
}
|
|
});
|
|
|
|
// Auto-reconnect when stream closes (container stop/restart)
|
|
stream.on("end", () => {
|
|
stream = null;
|
|
if (!destroyed) reconnectTimer = setTimeout(connect, 2000);
|
|
});
|
|
stream.on("error", () => {
|
|
stream = null;
|
|
if (!destroyed) reconnectTimer = setTimeout(connect, 2000);
|
|
});
|
|
}).catch(() => {
|
|
if (!destroyed) reconnectTimer = setTimeout(connect, 2000);
|
|
});
|
|
}
|
|
|
|
connect();
|
|
|
|
return {
|
|
destroy() {
|
|
destroyed = true;
|
|
clearTimeout(reconnectTimer);
|
|
if (stream) {
|
|
destroyStream(stream);
|
|
stream = null;
|
|
}
|
|
},
|
|
};
|
|
}
|