// System monitor adapter interface and implementations // These adapters parse command output into structured metrics data. // The actual command execution happens via IPC (netcatty:ssh:exec). import type { CpuUsage, MemoryUsage, DiskUsage, NetworkTraffic, SystemLoad, ProcessInfo, DockerContainer, ListeningPort, SystemService, SystemMetricsSnapshot, } from '../../domain/systemMonitor'; /** * Adapter interface for collecting system metrics from a remote host. * Each OS/device type has its own implementation. */ export interface SystemMonitorAdapter { /** OS family this adapter handles */ readonly osFamily: 'linux' | 'windows' | 'network'; /** Build a single command that collects all fast metrics (CPU, mem, load, uptime) */ buildFastMetricsCommand(): string; /** Parse the output of fast metrics command */ parseFastMetrics(output: string): Partial; /** Build command for disk usage */ buildDiskCommand(): string; /** Parse disk usage output */ parseDiskOutput(output: string): DiskUsage[]; /** Build command for network traffic (current snapshot) */ buildNetworkCommand(): string; /** Parse network output, needs prev snapshot for speed calc */ parseNetworkOutput(output: string, prev?: NetworkTraffic[], intervalMs?: number): NetworkTraffic[]; /** Build command for process list */ buildProcessCommand(limit?: number): string; /** Parse process list output */ parseProcessOutput(output: string): ProcessInfo[]; /** Build command for docker containers */ buildDockerCommand(): string; /** Parse docker output */ parseDockerOutput(output: string): DockerContainer[]; /** Build command for listening ports */ buildPortsCommand(): string; /** Parse listening ports output */ parsePortsOutput(output: string): ListeningPort[]; /** Build command for system services */ buildServicesCommand(): string; /** Parse services output */ parseServicesOutput(output: string): SystemService[]; } // ============================================================================ // Linux adapter // ============================================================================ export class LinuxMonitorAdapter implements SystemMonitorAdapter { readonly osFamily = 'linux' as const; buildFastMetricsCommand(): string { // Collect CPU, memory, load, uptime, hostname in one shot // Using /proc for reliable parsing, plus uptime and hostname return `cat /proc/stat | head -1; echo "---MEM---"; cat /proc/meminfo; echo "---LOAD---"; cat /proc/loadavg; echo "---UPTIME---"; cat /proc/uptime; echo "---HOST---"; hostname; echo "---OS---"; cat /etc/os-release 2>/dev/null | head -5 || uname -r`; } parseFastMetrics(output: string): Partial { const result: Partial = {}; const sections = output.split(/^---[A-Z]+---$/m); // CPU from /proc/stat (first line) const cpuLine = sections[0]?.trim().split('\n')[0]; if (cpuLine) { const parts = cpuLine.trim().split(/\s+/); // cpu user nice system idle iowait irq softirq steal guest guest_nice if (parts.length >= 5) { const user = parseInt(parts[1]) || 0; const nice = parseInt(parts[2]) || 0; const system = parseInt(parts[3]) || 0; const idle = parseInt(parts[4]) || 0; const iowait = parts[5] ? parseInt(parts[5]) : 0; const total = user + nice + system + idle + iowait + (parts[6] ? parseInt(parts[6]) : 0) + (parts[7] ? parseInt(parts[7]) : 0) + (parts[8] ? parseInt(parts[8]) : 0); // We can't compute percentage without previous sample, so we store raw // For single snapshot, try to use more clever approach result.cpu = { totalPercent: 0, // will be calculated with delta cores: [], userPercent: 0, systemPercent: 0, idlePercent: 0, }; (result.cpu as any)._rawTotal = total; (result.cpu as any)._rawIdle = idle; (result.cpu as any)._rawUser = user; (result.cpu as any)._rawSystem = system; } } // Memory from /proc/meminfo const memSection = sections[1]; if (memSection) { const memInfo = this.parseMemInfo(memSection); if (memInfo) result.memory = memInfo; } // Load average const loadSection = sections[2]; if (loadSection) { const loadParts = loadSection.trim().split(/\s+/); if (loadParts.length >= 3) { result.load = { load1: parseFloat(loadParts[0]) || 0, load5: parseFloat(loadParts[1]) || 0, load15: parseFloat(loadParts[2]) || 0, }; } } // Uptime const uptimeSection = sections[3]; if (uptimeSection) { const uptimeVal = parseFloat(uptimeSection.trim().split(/\s+/)[0]); if (!isNaN(uptimeVal)) result.uptimeSeconds = Math.floor(uptimeVal); } // Hostname const hostSection = sections[4]; if (hostSection) { result.hostname = hostSection.trim(); } // OS info const osSection = sections[5]; if (osSection) { const prettyMatch = osSection.match(/PRETTY_NAME="?([^"\n]+)"?/); if (prettyMatch) result.osName = prettyMatch[1]; } return result; } private parseMemInfo(output: string): MemoryUsage | null { const lines = output.trim().split('\n'); const info: Record = {}; for (const line of lines) { const m = line.match(/^(\S+):\s+(\d+)/); if (m) info[m[1]] = parseInt(m[2]) * 1024; // kB -> bytes } const total = info['MemTotal'] || 0; const free = info['MemFree'] || 0; const buffers = info['Buffers'] || 0; const cached = info['Cached'] || 0; const sreclaimable = info['SReclaimable'] || 0; const used = total - free - buffers - cached - sreclaimable; const percent = total > 0 ? (used / total) * 100 : 0; return { totalBytes: total, usedBytes: used, freeBytes: free + buffers + cached + sreclaimable, percent, cachedBytes: cached + buffers + sreclaimable, swapUsedBytes: (info['SwapTotal'] || 0) - (info['SwapFree'] || 0), swapTotalBytes: info['SwapTotal'] || 0, }; } buildDiskCommand(): string { return `df -PT -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null | tail -n +2`; } parseDiskOutput(output: string): DiskUsage[] { const lines = output.trim().split('\n').filter(l => l.trim()); const disks: DiskUsage[] = []; for (const line of lines) { const parts = line.trim().split(/\s+/); // Filesystem Type Size Used Avail Use% Mounted on if (parts.length >= 7) { const total = parseInt(parts[2]) * 1024; // KB -> bytes const used = parseInt(parts[3]) * 1024; const avail = parseInt(parts[4]) * 1024; const usePercent = parseInt(parts[5]); const mountPoint = parts.slice(6).join(' '); disks.push({ filesystem: parts[0], mountPoint, totalBytes: total, usedBytes: used, freeBytes: avail, percent: usePercent, }); } } return disks; } buildNetworkCommand(): string { return `cat /proc/net/dev | tail -n +3`; } parseNetworkOutput(output: string, prev?: NetworkTraffic[], intervalMs: number = 2000): NetworkTraffic[] { const lines = output.trim().split('\n').filter(l => l.trim()); const current: Map = new Map(); for (const line of lines) { const parts = line.trim().split(/[:\s]+/); if (parts.length >= 10) { const iface = parts[0]; if (iface === 'lo' || !iface) continue; const rxBytes = parseInt(parts[1]) || 0; const txBytes = parseInt(parts[9]) || 0; current.set(iface, { rx: rxBytes, tx: txBytes }); } } const results: NetworkTraffic[] = []; const intervalSec = intervalMs / 1000; for (const [iface, curr] of current) { const prevItem = prev?.find(p => p.interfaceName === iface); let rxSpeed = 0; let txSpeed = 0; if (prevItem && prevItem.rxTotalBytes !== undefined && prevItem.txTotalBytes !== undefined) { rxSpeed = Math.max(0, (curr.rx - prevItem.rxTotalBytes) / intervalSec); txSpeed = Math.max(0, (curr.tx - prevItem.txTotalBytes) / intervalSec); } results.push({ interfaceName: iface, rxBytesPerSec: rxSpeed, txBytesPerSec: txSpeed, rxTotalBytes: curr.rx, txTotalBytes: curr.tx, }); } return results; } buildProcessCommand(limit: number = 20): string { return `ps -eo pid,user,%cpu,%mem,rss,comm,stat --sort=-%cpu --no-headers | head -${limit}`; } parseProcessOutput(output: string): ProcessInfo[] { const lines = output.trim().split('\n').filter(l => l.trim()); const processes: ProcessInfo[] = []; for (const line of lines) { const parts = line.trim().split(/\s+/); if (parts.length >= 7) { processes.push({ pid: parseInt(parts[0]) || 0, user: parts[1], cpuPercent: parseFloat(parts[2]) || 0, memPercent: parseFloat(parts[3]) || 0, memBytes: (parseInt(parts[4]) || 0) * 1024, // KB -> bytes name: parts[5], status: parts[6], }); } } return processes; } buildDockerCommand(): string { return `docker ps -a --format "{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}|{{.State}}" 2>/dev/null || echo "DOCKER_NOT_FOUND"`; } parseDockerOutput(output: string): DockerContainer[] { if (output.includes('DOCKER_NOT_FOUND') || output.trim() === '') return []; const lines = output.trim().split('\n').filter(l => l.trim()); const containers: DockerContainer[] = []; for (const line of lines) { const parts = line.split('|'); if (parts.length >= 5) { containers.push({ id: parts[0], name: parts[1], image: parts[2], status: parts[3], state: (parts[4].toLowerCase() as DockerContainer['state']) || 'unknown', }); } } return containers; } buildPortsCommand(): string { return `ss -tulnp 2>/dev/null || netstat -tulnp 2>/dev/null || echo "PORTS_NOT_AVAILABLE"`; } parsePortsOutput(output: string): ListeningPort[] { if (output.includes('PORTS_NOT_AVAILABLE')) return []; const lines = output.trim().split('\n').filter(l => l.trim()); const ports: ListeningPort[] = []; const seen = new Set(); for (const line of lines) { if (line.startsWith('State') || line.startsWith('Proto')) continue; const parts = line.trim().split(/\s+/); if (parts.length < 5) continue; const proto = parts[0].toLowerCase().includes('tcp') ? 'tcp' : parts[0].toLowerCase().includes('udp') ? 'udp' : 'tcp'; const localAddr = parts.find(p => p.includes(':') && !p.startsWith('0x')); if (!localAddr) continue; const lastColonIdx = localAddr.lastIndexOf(':'); const addr = localAddr.substring(0, lastColonIdx).replace(/^\[|\]$/g, ''); const portStr = localAddr.substring(lastColonIdx + 1); const port = parseInt(portStr); if (!port || isNaN(port)) continue; const key = `${proto}:${port}:${addr}`; if (seen.has(key)) continue; seen.add(key); // Try to find process info let processName = ''; let pid: number | undefined; const procMatch = line.match(/users:\(\("([^"]+)"/); const pidMatch = line.match(/pid=(\d+)/); if (procMatch) processName = procMatch[1]; if (pidMatch) pid = parseInt(pidMatch[1]); ports.push({ port, protocol: proto as 'tcp' | 'udp', address: addr || '0.0.0.0', process: processName || undefined, pid, state: parts[1] || undefined, }); } return ports.sort((a, b) => a.port - b.port); } buildServicesCommand(): string { return `systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null || echo "SYSTEMD_NOT_FOUND"`; } parseServicesOutput(output: string): SystemService[] { if (output.includes('SYSTEMD_NOT_FOUND')) return []; const lines = output.trim().split('\n').filter(l => l.trim()); const services: SystemService[] = []; for (const line of lines) { const parts = line.trim().split(/\s+/); if (parts.length < 4) continue; const name = parts[0]; const load = parts[1]; const active = parts[2]; const sub = parts[3]; const desc = parts.slice(4).join(' '); let state: SystemService['state'] = 'unknown'; if (active === 'active' && (sub === 'running' || sub === 'exited')) state = 'running'; else if (active === 'inactive' || active === 'failed') state = active === 'failed' ? 'failed' : 'stopped'; services.push({ name, state, description: desc, }); } return services.slice(0, 50); // limit } } // ============================================================================ // Windows adapter (for SSH-connected Windows servers) // ============================================================================ export class WindowsMonitorAdapter implements SystemMonitorAdapter { readonly osFamily = 'windows' as const; buildFastMetricsCommand(): string { return `powershell -NoProfile -Command " $cpu = Get-WmiObject Win32_Processor | Measure-Object -Property LoadPercentage -Average | Select-Object -ExpandProperty Average; $os = Get-WmiObject Win32_OperatingSystem; $memTotal = $os.TotalVisibleMemorySize * 1024; $memFree = $os.FreePhysicalMemory * 1024; $memUsed = $memTotal - $memFree; $memPct = if ($memTotal -gt 0) { [math]::Round(($memUsed / $memTotal) * 100, 1) } else { 0 }; $uptime = (Get-Date) - $os.ConvertToDateTime($os.LastBootUpTime); $hostName = $env:COMPUTERNAME; $osName = $os.Caption; Write-Output \"CPU:$cpu\"; Write-Output \"MEM_TOTAL:$memTotal\"; Write-Output \"MEM_USED:$memUsed\"; Write-Output \"MEM_FREE:$memFree\"; Write-Output \"MEM_PCT:$memPct\"; Write-Output \"UPTIME:$([int]$uptime.TotalSeconds)\"; Write-Output \"HOSTNAME:$hostName\"; Write-Output \"OS:$osName\"; " 2>$null`; } parseFastMetrics(output: string): Partial { const result: Partial = {}; const lines = output.trim().split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('CPU:')) { const val = parseFloat(trimmed.substring(4)) || 0; result.cpu = { totalPercent: val, cores: [] }; } else if (trimmed.startsWith('MEM_TOTAL:')) { if (!result.memory) result.memory = { totalBytes: 0, usedBytes: 0, freeBytes: 0, percent: 0 }; result.memory.totalBytes = parseInt(trimmed.substring(10)) || 0; } else if (trimmed.startsWith('MEM_USED:')) { if (!result.memory) result.memory = { totalBytes: 0, usedBytes: 0, freeBytes: 0, percent: 0 }; result.memory.usedBytes = parseInt(trimmed.substring(9)) || 0; } else if (trimmed.startsWith('MEM_FREE:')) { if (!result.memory) result.memory = { totalBytes: 0, usedBytes: 0, freeBytes: 0, percent: 0 }; result.memory.freeBytes = parseInt(trimmed.substring(9)) || 0; } else if (trimmed.startsWith('MEM_PCT:')) { if (!result.memory) result.memory = { totalBytes: 0, usedBytes: 0, freeBytes: 0, percent: 0 }; result.memory.percent = parseFloat(trimmed.substring(8)) || 0; } else if (trimmed.startsWith('UPTIME:')) { result.uptimeSeconds = parseInt(trimmed.substring(7)) || 0; } else if (trimmed.startsWith('HOSTNAME:')) { result.hostname = trimmed.substring(9); } else if (trimmed.startsWith('OS:')) { result.osName = trimmed.substring(3); } } return result; } buildDiskCommand(): string { return `powershell -NoProfile -Command "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Used -gt 0 } | ForEach-Object { $total = $_.Used + $_.Free; $pct = if ($total -gt 0) { [math]::Round(($_.Used / $total) * 100, 1) } else { 0 }; Write-Output '$($_.Name)|$total|$($_.Used)|$($_.Free)|$pct|$($_.Root)' }" 2>$null`; } parseDiskOutput(output: string): DiskUsage[] { const lines = output.trim().split('\n').filter(l => l.trim()); const disks: DiskUsage[] = []; for (const line of lines) { const parts = line.trim().split('|'); if (parts.length >= 6) { disks.push({ filesystem: parts[0], mountPoint: parts[5], totalBytes: parseInt(parts[1]) || 0, usedBytes: parseInt(parts[2]) || 0, freeBytes: parseInt(parts[3]) || 0, percent: parseFloat(parts[4]) || 0, }); } } return disks; } buildNetworkCommand(): string { return `powershell -NoProfile -Command "Get-NetAdapterStatistics -ErrorAction SilentlyContinue | ForEach-Object { Write-Output '$($_.Name)|$($_.ReceivedBytes)|$($_.SentBytes)' }" 2>$null`; } parseNetworkOutput(output: string, prev?: NetworkTraffic[], intervalMs: number = 2000): NetworkTraffic[] { const lines = output.trim().split('\n').filter(l => l.trim()); const current: Map = new Map(); for (const line of lines) { const parts = line.trim().split('|'); if (parts.length >= 3) { current.set(parts[0], { rx: parseInt(parts[1]) || 0, tx: parseInt(parts[2]) || 0, }); } } const results: NetworkTraffic[] = []; const intervalSec = intervalMs / 1000; for (const [iface, curr] of current) { const prevItem = prev?.find(p => p.interfaceName === iface); let rxSpeed = 0; let txSpeed = 0; if (prevItem && prevItem.rxTotalBytes !== undefined && prevItem.txTotalBytes !== undefined) { rxSpeed = Math.max(0, (curr.rx - prevItem.rxTotalBytes) / intervalSec); txSpeed = Math.max(0, (curr.tx - prevItem.txTotalBytes) / intervalSec); } results.push({ interfaceName: iface, rxBytesPerSec: rxSpeed, txBytesPerSec: txSpeed, rxTotalBytes: curr.rx, txTotalBytes: curr.tx, }); } return results; } buildProcessCommand(limit: number = 20): string { return `powershell -NoProfile -Command "Get-Process | Sort-Object CPU -Descending | Select-Object -First ${limit} | ForEach-Object { $cpuPct = if ($_.CPU -ne $null) { [math]::Round($_.CPU, 1) } else { 0 }; $memPct = 0; $name = $_.ProcessName; Write-Output '$($_.Id)|$($_.UserName ?? '')|$cpuPct|$memPct|$($_.WorkingSet64)|$name|$($_.StartTime)' }" 2>$null`; } parseProcessOutput(output: string): ProcessInfo[] { const lines = output.trim().split('\n').filter(l => l.trim()); const processes: ProcessInfo[] = []; for (const line of lines) { const parts = line.trim().split('|'); if (parts.length >= 6) { processes.push({ pid: parseInt(parts[0]) || 0, user: parts[1] || undefined, cpuPercent: parseFloat(parts[2]) || 0, memPercent: parseFloat(parts[3]) || 0, memBytes: parseInt(parts[4]) || 0, name: parts[5], }); } } return processes; } buildDockerCommand(): string { return `docker ps -a --format "{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}|{{.State}}" 2>$null`; } parseDockerOutput(output: string): DockerContainer[] { if (output.trim() === '' || output.includes('not found')) return []; const lines = output.trim().split('\n').filter(l => l.trim()); const containers: DockerContainer[] = []; for (const line of lines) { const parts = line.split('|'); if (parts.length >= 5) { containers.push({ id: parts[0], name: parts[1], image: parts[2], status: parts[3], state: (parts[4].toLowerCase().trim() as DockerContainer['state']) || 'unknown', }); } } return containers; } buildPortsCommand(): string { return `powershell -NoProfile -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Write-Output '$($_.LocalAddress)|$($_.LocalPort)|TCP|$($_.OwningProcess)' }; Get-NetUDPEndpoint -ErrorAction SilentlyContinue | ForEach-Object { Write-Output '$($_.LocalAddress)|$($_.LocalPort)|UDP|$($_.OwningProcess)' }" 2>$null`; } parsePortsOutput(output: string): ListeningPort[] { const lines = output.trim().split('\n').filter(l => l.trim()); const ports: ListeningPort[] = []; const seen = new Set(); for (const line of lines) { const parts = line.trim().split('|'); if (parts.length >= 4) { const addr = parts[0].replace(/^\[|\]$/g, ''); const port = parseInt(parts[1]); const proto = parts[2].toLowerCase() as 'tcp' | 'udp'; const pid = parseInt(parts[3]); const key = `${proto}:${port}:${addr}`; if (seen.has(key) || !port) continue; seen.add(key); ports.push({ port, protocol: proto, address: addr || '0.0.0.0', pid: pid || undefined, }); } } return ports.sort((a, b) => a.port - b.port); } buildServicesCommand(): string { return `powershell -NoProfile -Command "Get-Service | ForEach-Object { $state = switch ($_.Status) { 'Running' { 'running' } 'Stopped' { 'stopped' } default { 'unknown' } }; $startType = switch ($_.StartType) { 'Automatic' { 'auto' } 'Manual' { 'manual' } 'Disabled' { 'disabled' } default { 'manual' } }; Write-Output '$($_.Name)|$state|$($_.DisplayName)|$startType' }" 2>$null`; } parseServicesOutput(output: string): SystemService[] { const lines = output.trim().split('\n').filter(l => l.trim()); const services: SystemService[] = []; for (const line of lines) { const parts = line.trim().split('|'); if (parts.length >= 4) { services.push({ name: parts[0], state: parts[1] as SystemService['state'], description: parts[2], startupType: parts[3] as SystemService['startupType'], }); } } return services.slice(0, 50); } } /** * Get the appropriate monitor adapter based on OS type. */ export function getMonitorAdapter(osType: 'linux' | 'windows' | 'macos' | string): SystemMonitorAdapter { switch (osType) { case 'windows': return new WindowsMonitorAdapter(); case 'linux': case 'macos': default: return new LinuxMonitorAdapter(); } }