import { Router, type Request, type Response } from 'express'; import path from 'path'; import si from 'systeminformation'; import DockerController, { globalDockerNetwork } from '../services/DockerController'; import { DatabaseService } from '../services/DatabaseService'; import { CacheService } from '../services/CacheService'; import { NodeRegistry } from '../services/NodeRegistry'; import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; import { STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS } from '../helpers/constants'; import { getHostMemory, memoryToWire } from '../helpers/hostMemory'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { isManagedByComposeDir } from '../utils/managed-containers'; import { GlobalLogsMetrics } from '../services/GlobalLogsMetrics'; import { type GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog, createFrameDemuxer, } from '../utils/log-parsing'; export const metricsRouter = Router(); // Lines of history each container replays when a feed opens. Bounds the // open-time burst (was 500 per container, multiplied across every container). const STREAM_INITIAL_TAIL = 200; const POLL_TAIL = 100; // Hard cap on simultaneous `docker logs --follow` streams behind one SSE // connection. Beyond this the feed is truncated and the operator is told. const MAX_FOLLOW_STREAMS = 60; // Concurrency limit for the polling snapshot's per-container Docker calls so a // large managed set does not fan out N simultaneous requests at the daemon. const POLL_CONCURRENCY = 8; interface ContainerSummary { Id: string; Names?: string[]; Labels?: Record; } /** Read a managed-only set of running containers for the node. */ async function getManagedRunningContainers( nodeId: number, ): Promise<{ containers: ContainerSummary[]; total: number }> { const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId)); const all = (await DockerController.getInstance(nodeId).getRunningContainers()) as ContainerSummary[]; const containers = all.filter(c => isManagedByComposeDir(c, composeDir)); return { containers, total: all.length }; } /** Map a Docker container summary to its display stack + container name. */ function describeContainer(c: ContainerSummary): { stackName: string; containerName: string } { const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); return { stackName, containerName: normalizeContainerName(rawName, stackName) }; } /** * Run an async mapper over items with a bounded number in flight. The caller's * `fn` must handle its own errors: a rejection propagates and abandons the * remaining work (the log endpoints wrap their body in try/catch for this). * Exported for unit testing. */ export async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { let cursor = 0; const worker = async (): Promise => { while (cursor < items.length) { const item = items[cursor++]; await fn(item); } }; const workerCount = Math.min(limit, items.length); await Promise.all(Array.from({ length: workerCount }, () => worker())); } /** * Container stats aggregated for the dashboard. Cached per-node for 2s to * collapse multi-tab polling pressure. Write-path endpoints (deploy, down, * start, stop, restart) invalidate this key via `invalidateNodeCaches`. */ metricsRouter.get('/stats', authMiddleware, async (req: Request, res: Response): Promise => { try { const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(req.nodeId)); const result = await CacheService.getInstance().getOrFetch( `stats:${req.nodeId}`, STATS_CACHE_TTL_MS, async () => { const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers(); type ContainerInfo = { State?: string; Labels?: Record }; const cs = allContainers as ContainerInfo[]; const active = cs.filter(c => c.State === 'running').length; const exited = cs.filter(c => c.State === 'exited').length; const total = cs.length; const managed = cs.filter(c => c.State === 'running' && isManagedByComposeDir(c, composeDir)).length; const unmanaged = cs.filter(c => c.State === 'running' && !isManagedByComposeDir(c, composeDir)).length; return { active, managed, unmanaged, exited, total }; }, ); res.json(result); } catch { res.status(500).json({ error: 'Failed to fetch stats' }); } }); metricsRouter.get('/metrics/historical', authMiddleware, async (_req: Request, res: Response): Promise => { try { const metrics = DatabaseService.getInstance().getContainerMetrics(24); res.json(metrics); } catch { res.status(500).json({ error: 'Failed to fetch metrics' }); } }); metricsRouter.get('/logs/global', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { GlobalLogsMetrics.increment('poll_requests_total'); const debug = isDebugEnabled(); const dockerController = DockerController.getInstance(req.nodeId); const { containers, total } = await getManagedRunningContainers(req.nodeId); const allLogs: GlobalLogEntry[] = []; if (debug) console.debug('[GlobalLogs:debug] Polling snapshot starting', { managed: containers.length, total, nodeId: req.nodeId }); await mapWithConcurrency(containers, POLL_CONCURRENCY, async (c) => { const { stackName, containerName } = describeContainer(c); try { const container = dockerController.getDocker().getContainer(c.Id); const inspect = await container.inspect(); const isTty = inspect.Config.Tty; const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: POLL_TAIL, timestamps: true }) as Buffer; demuxDockerLog(logsBuffer, isTty, (line, source) => { if (!line.trim()) return; const { timestampMs, cleanMessage } = parseLogTimestamp(line); const level = detectLogLevel(cleanMessage, source); allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampMs }); }); } catch (err) { // Mirror the SSE path so per-container read failures are visible on the // same counter rather than only in the log. GlobalLogsMetrics.increment('stream_attach_errors_total'); console.warn(`[GlobalLogs] Failed to fetch/parse logs for container ${containerName} (${c.Id.substring(0, 12)}):`, getErrorMessage(err, 'unknown')); } }); // Sort ascending by timestamp (newest bottom). Limit to 500 lines; the // client only renders ~300 at a time. allLogs.sort((a, b) => a.timestampMs - b.timestampMs); const snapshot = allLogs.slice(-500); GlobalLogsMetrics.increment('lines_streamed_total', snapshot.length); if (debug) console.debug('[GlobalLogs:debug] Polling snapshot complete', { totalLines: allLogs.length, returned: snapshot.length }); res.json(snapshot); } catch (error) { console.error('[GlobalLogs] Snapshot fetch failed:', getErrorMessage(error, 'unknown')); res.status(500).json({ error: 'Failed to fetch global logs' }); } }); metricsRouter.get('/logs/global/stream', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // Prevent nginx from buffering SSE events (would cause burst delivery). res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); const debug = isDebugEnabled(); const dockerController = DockerController.getInstance(req.nodeId); const streams: NodeJS.ReadableStream[] = []; GlobalLogsMetrics.openConnection(); let closed = false; let paused = false; const destroyStream = (s: NodeJS.ReadableStream): void => { try { (s as NodeJS.ReadableStream & { destroy(): void }).destroy(); } catch { /* already ended */ } }; // Back off every source follow-stream when the socket buffer fills, then // resume on 'drain', so a slow client cannot drive unbounded Node-side // buffering across N concurrent streams. const pauseAll = (): void => { if (paused) return; paused = true; streams.forEach(s => { try { s.pause(); } catch { /* ended */ } }); }; const resumeAll = (): void => { if (!paused) return; paused = false; streams.forEach(s => { try { s.resume(); } catch { /* ended */ } }); }; res.on('drain', resumeAll); const writeEvent = (entry: GlobalLogEntry): void => { if (res.writableEnded) return; const ok = res.write(`data: ${JSON.stringify(entry)}\n\n`); GlobalLogsMetrics.increment('lines_streamed_total'); if (!ok) pauseAll(); }; // SSE heartbeat (: prefix is a comment, silently dropped by EventSource) // every 30s keeps reverse proxies from closing idle connections. Honor // backpressure here too so a heartbeat that fills the socket buffer still // pauses the source streams. const heartbeat = setInterval(() => { if (!res.writableEnded && !res.write(':heartbeat\n\n')) pauseAll(); }, 30_000); const cleanup = (): void => { if (closed) return; closed = true; clearInterval(heartbeat); res.removeListener('drain', resumeAll); if (debug) console.debug('[GlobalLogs:debug] SSE stream closed, cleaning up', { streamCount: streams.length }); streams.forEach(destroyStream); GlobalLogsMetrics.closeConnection(); }; req.on('close', cleanup); try { const { containers, total } = await getManagedRunningContainers(req.nodeId); const followSet = containers.slice(0, MAX_FOLLOW_STREAMS); const truncated = containers.length - followSet.length; if (debug) console.debug('[GlobalLogs:debug] SSE stream opened', { managed: containers.length, total, following: followSet.length, nodeId: req.nodeId }); await Promise.all(followSet.map(async (c) => { const { stackName, containerName } = describeContainer(c); try { const container = dockerController.getDocker().getContainer(c.Id); const inspect = await container.inspect(); const isTty = inspect.Config.Tty; const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: STREAM_INITIAL_TAIL, timestamps: true }); // The connection may have closed while we awaited inspect/logs. if (closed) { destroyStream(stream); return; } streams.push(stream); if (paused) { try { stream.pause(); } catch { /* ended */ } } const demuxer = createFrameDemuxer( isTty, (line, source) => { if (!line.trim()) return; const { timestampMs, cleanMessage } = parseLogTimestamp(line); const level = detectLogLevel(cleanMessage, source); writeEvent({ stackName, containerName, source, level, message: cleanMessage, timestampMs }); }, () => GlobalLogsMetrics.increment('demux_frame_errors_total'), ); stream.on('data', (chunk: Buffer) => demuxer.push(chunk)); // Drain the demuxer's buffered trailing line (one with no newline, the // common shape of a crash/exit line) when the follow stream ends or // breaks, so the last thing a container said is not silently lost. stream.on('end', () => demuxer.flush()); stream.on('error', (err) => { GlobalLogsMetrics.increment('stream_attach_errors_total'); console.warn(`[GlobalLogs] Follow stream error for ${containerName} (${c.Id.substring(0, 12)}):`, getErrorMessage(err, 'unknown')); demuxer.flush(); // One degraded notice per drop (not per failed read) so the operator // sees the gap in the feed and the WARNINGS tile without log spam. writeEvent({ stackName, containerName, source: 'STDERR', level: 'WARN', message: `[Sencho] Log stream for ${containerName} ended unexpectedly; reopen the tab to resume.`, timestampMs: Date.now() }); destroyStream(stream); }); } catch (err) { GlobalLogsMetrics.increment('stream_attach_errors_total'); console.warn(`[GlobalLogs] Failed to attach stream for container ${containerName} (${c.Id.substring(0, 12)}):`, getErrorMessage(err, 'unknown')); } })); if (truncated > 0) { writeEvent({ stackName: 'system', containerName: 'sencho', source: 'STDOUT', level: 'WARN', message: `[Sencho] Following ${followSet.length} of ${containers.length} managed containers; ${truncated} not shown. Use the per-container log viewer for the rest.`, timestampMs: Date.now() }); } } catch (error) { console.error('[GlobalLogs] SSE stream attachment failed:', getErrorMessage(error, 'unknown')); if (!res.writableEnded) { res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`); } cleanup(); if (!res.writableEnded) res.end(); } }); /** * Host-level CPU / memory / disk / network sample. Cached for 3s to collapse * overlapping samplers (dashboard polls every 5s, MonitorService samples * every 30s, `si.currentLoad()` blocks ~200ms per call). No write-path * invalidation: these are pure host metrics. */ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Response): Promise => { try { // Network is read outside the cache because it is cheap and per-request. const rxSec = Math.max(0, globalDockerNetwork.rxSec); const txSec = Math.max(0, globalDockerNetwork.txSec); const sample = await CacheService.getInstance().getOrFetch( `system-stats:${req.nodeId}`, SYSTEM_STATS_CACHE_TTL_MS, async () => { // Remote-node requests are intercepted and proxied upstream before // reaching here; this fetcher only runs for local nodes. const [currentLoad, hostMem, fsSize] = await Promise.all([ si.currentLoad(), getHostMemory(), si.fsSize(), ]); const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; return { cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length, }, // ARC/balloon aware: reclaimable ARC is added back into available, // and ballooned memory is subtracted from used. See helpers/hostMemory.ts. memory: memoryToWire(hostMem), disk: mainDisk ? { fs: mainDisk.fs, mount: mainDisk.mount, total: mainDisk.size, used: mainDisk.used, free: mainDisk.available, usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', } : null, }; }, ); res.json({ ...sample, network: { rxBytes: 0, txBytes: 0, rxSec, txSec } }); } catch (error) { console.error('Failed to fetch system stats:', error); res.status(500).json({ error: 'Failed to fetch system stats' }); } }); /** * Admin-only cache observability. Surfaces per-namespace hit/miss/stale * counters and live entry counts for the unified CacheService. Used by * Settings → About and for post-deploy verification that cache hit rates * look healthy. */ metricsRouter.get('/system/cache-stats', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { res.json(CacheService.getInstance().getStats()); } catch (error) { console.error('Failed to fetch cache stats:', error); res.status(500).json({ error: 'Failed to fetch cache stats' }); } }); /** * Admin-only pilot tunnel observability. Counters reset on process restart * by design (see PilotMetrics.ts). The per_node array is the load-bearing * field: aggregate counters can hide a single tunnel that is flapping or * sitting on a stuck write buffer. */ metricsRouter.get('/system/pilot-tunnels', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const snapshot = PilotTunnelManager.getInstance().getMetricsSnapshot(); res.json(snapshot); } catch (error) { console.error('Failed to fetch pilot tunnel metrics:', error); res.status(500).json({ error: 'Failed to fetch pilot tunnel metrics' }); } }); /** * Admin-only Global Observability log-stream observability. Process-local, * in-memory counters (reset on restart by design; see GlobalLogsMetrics). The * `active_sse_connections` gauge is the load-bearing field: it should drain to * zero when no Logs tab is open, and a rising `stream_attach_errors_total` or * `demux_frame_errors_total` points at a daemon or stream-corruption problem. */ metricsRouter.get('/system/log-stream-metrics', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { res.json(GlobalLogsMetrics.snapshot()); } catch (error) { console.error('Failed to fetch log-stream metrics:', error); res.status(500).json({ error: 'Failed to fetch log-stream metrics' }); } });