Files
NetMesh/infrastructure/systemMonitor/systemMonitorAdapters.ts

618 lines
22 KiB
TypeScript
Raw Normal View History

// 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<SystemMetricsSnapshot>;
/** 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<SystemMetricsSnapshot> {
const result: Partial<SystemMetricsSnapshot> = {};
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<string, number> = {};
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<string, { rx: number; tx: number }> = 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<string>();
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<SystemMetricsSnapshot> {
const result: Partial<SystemMetricsSnapshot> = {};
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<string, { rx: number; tx: number }> = 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<string>();
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();
}
}