feat: add SSH metrics collection with ssh2 library and auto-scheduler

Replace sshpass with ssh2 Node.js library for reliable SSH connections.
Add all 6 servers (CWP, EasyPanel, MCP Hub, Meet, WhatsApp, WhatSMS).
Add 5-minute auto-collection scheduler in production mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 22:14:22 +00:00
parent 37164cf2ac
commit 10fc8f5ccc
4 changed files with 255 additions and 107 deletions

View File

@@ -13,6 +13,7 @@ import diagnosticRouter from './routes/diagnostic.js'
import hetznerRouter from './routes/hetzner.js'
import wpMonitorRouter from './routes/wp-monitor.js'
import serverMetricsRouter from './routes/server-metrics.js'
import { collectAllServerMetrics } from './services/server-metrics.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -65,10 +66,29 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
// Start server
app.listen(PORT, () => {
console.log('='.repeat(50))
console.log(`🚀 API Server running on http://localhost:${PORT}`)
console.log(`📊 Dashboard: http://localhost:${PORT}/api/dashboard`)
console.log(`🔍 Monitor: http://localhost:${PORT}/api/monitor`)
console.log(`🔧 Diagnostic: http://localhost:${PORT}/api/diagnostic`)
console.log(`☁️ Hetzner: http://localhost:${PORT}/api/hetzner`)
console.log(`API Server running on http://localhost:${PORT}`)
console.log(`Dashboard: http://localhost:${PORT}/api/dashboard`)
console.log(`Monitor: http://localhost:${PORT}/api/monitor`)
console.log(`Hetzner: http://localhost:${PORT}/api/hetzner`)
console.log('='.repeat(50))
// Auto-collect server metrics every 5 minutes
if (isProduction) {
const INTERVAL = 5 * 60 * 1000
console.log('[SCHEDULER] Server metrics collection every 5min')
// Initial collection after 30s (let server stabilize)
setTimeout(() => {
collectAllServerMetrics().catch(err =>
console.error('[SCHEDULER] Initial collection failed:', err)
)
}, 30000)
// Recurring collection
setInterval(() => {
collectAllServerMetrics().catch(err =>
console.error('[SCHEDULER] Collection failed:', err)
)
}, INTERVAL)
}
})

View File

@@ -1,18 +1,16 @@
/**
* Server Metrics Collector Service
* Recolhe métricas de todos os servidores (Hetzner API + SSH)
* Recolhe métricas de todos os servidores via SSH (ssh2 library)
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import db from '../db.js'
import { collectAllMetrics as collectHetznerMetrics } from './hetzner.js'
import { Client } from 'ssh2'
// Hetzner API Configuration (used by hetzner.ts service)
// SSH Configuration (from MCP ssh-unified)
interface SSHServer {
name: string
monitorName: string
host: string
port: number
user: string
pass: string
}
@@ -22,6 +20,7 @@ const SSH_SERVERS: SSHServer[] = [
name: 'server',
monitorName: 'CWP Server',
host: process.env.SERVER_HOST || '176.9.3.158',
port: parseInt(process.env.SERVER_PORT || '9443'),
user: process.env.SERVER_USER || 'root',
pass: process.env.SERVER_PASS || ''
},
@@ -29,19 +28,44 @@ const SSH_SERVERS: SSHServer[] = [
name: 'easy',
monitorName: 'EasyPanel',
host: process.env.EASY_HOST || '178.63.18.51',
port: 22,
user: process.env.EASY_USER || 'root',
pass: process.env.EASY_PASS || ''
},
{
name: 'mcp-hub',
monitorName: 'MCP Hub',
host: process.env.MCPHUB_HOST || 'mcp-hub.descomplicar.pt',
port: 22,
user: process.env.MCPHUB_USER || 'root',
pass: process.env.MCPHUB_PASS || ''
},
{
name: 'meet',
monitorName: 'Meet',
host: process.env.MEET_HOST || 'meet.descomplicar.pt',
port: 22,
user: process.env.MEET_USER || 'root',
pass: process.env.MEET_PASS || ''
},
{
name: 'whatsapp',
monitorName: 'WhatsApp',
host: process.env.WHATSAPP_HOST || 'whatsapp.descomplicar.pt',
port: 22,
user: process.env.WHATSAPP_USER || 'root',
pass: process.env.WHATSAPP_PASS || ''
},
{
name: 'whatsms',
monitorName: 'WhatSMS',
host: process.env.WHATSMS_HOST || 'whatsms.descomplicar.pt',
port: 22,
user: process.env.WHATSMS_USER || 'root',
pass: process.env.WHATSMS_PASS || ''
}
]
// Hetzner server mapping to monitoring table
const HETZNER_MAPPING: Record<string, string> = {
'gateway': 'MCP Hub',
'meet': 'Meet',
'whatsapp.descomplicar.pt': 'WhatsApp',
'whatsms': 'WhatSMS'
}
interface ServerMetrics {
cpu: number
ram: number
@@ -50,9 +74,6 @@ interface ServerMetrics {
containers?: number
}
/**
* Parse SSH metrics output
*/
function parseSSHMetrics(output: string): ServerMetrics {
const lines = output.split('\n')
const metrics: ServerMetrics = { cpu: 0, ram: 0, disk: 0, load: 0 }
@@ -69,29 +90,67 @@ function parseSSHMetrics(output: string): ServerMetrics {
}
/**
* Execute SSH command via subprocess (since we can't use MCP from here)
* This is a placeholder - in production, use the MCP or a proper SSH library
* Execute SSH command via ssh2 library
*/
async function executeSSH(server: SSHServer, command: string): Promise<string> {
// For now, we'll use the database values or call an external endpoint
// In production, you'd use ssh2 library or call the MCP endpoint
const { exec } = await import('child_process')
const { promisify } = await import('util')
const execAsync = promisify(exec)
function executeSSH(server: SSHServer, command: string): Promise<string> {
return new Promise((resolve, reject) => {
const conn = new Client()
let output = ''
const timeout = setTimeout(() => {
conn.end()
reject(new Error(`SSH timeout for ${server.name}`))
}, 20000)
try {
// Use sshpass for password-based auth (not ideal but works for internal servers)
const sshCommand = `sshpass -p '${server.pass}' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${server.user}@${server.host} "${command}"`
const { stdout } = await execAsync(sshCommand, { timeout: 30000 })
return stdout
} catch (error) {
console.error(`SSH error for ${server.name}:`, error)
return ''
}
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
clearTimeout(timeout)
conn.end()
reject(err)
return
}
stream.on('data', (data: Buffer) => {
output += data.toString()
})
stream.on('close', () => {
clearTimeout(timeout)
conn.end()
resolve(output)
})
stream.stderr.on('data', () => {
// ignore stderr
})
})
})
conn.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
conn.connect({
host: server.host,
port: server.port,
username: server.user,
password: server.pass,
readyTimeout: 15000,
algorithms: {
kex: [
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1'
]
}
})
})
}
/**
* Collect metrics from SSH servers (CWP, EasyPanel)
* Collect metrics from all SSH servers
*/
export async function collectSSHMetrics(): Promise<{ success: number; failed: number }> {
let success = 0
@@ -100,6 +159,12 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
const metricsCommand = `echo "CPU:$(top -bn1 | grep 'Cpu(s)' | awk '{print $2}')"; echo "MEM:$(free -m | awk '/Mem:/ {printf "%.1f", $3/$2*100}')"; echo "DISK:$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')"; echo "LOAD:$(cat /proc/loadavg | awk '{print $1}')"; echo "CONTAINERS:$(docker ps -q 2>/dev/null | wc -l || echo 0)"`
for (const server of SSH_SERVERS) {
if (!server.pass) {
console.log(`[SSH] Skipping ${server.name}: no password configured`)
failed++
continue
}
try {
const output = await executeSSH(server, metricsCommand)
if (!output) {
@@ -127,27 +192,30 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
// Update containers if EasyPanel
if (server.name === 'easy' && metrics.containers !== undefined) {
// Get current container stats
const containerOutput = await executeSSH(server, 'docker ps -a --format "{{.Status}}" | grep -c "Up" || echo 0; docker ps -aq | wc -l')
const [up, total] = containerOutput.trim().split('\n').map(n => parseInt(n) || 0)
const down = total - up
try {
const containerOutput = await executeSSH(server, 'docker ps -a --format "{{.Status}}" | grep -c "Up" || echo 0; docker ps -aq | wc -l')
const [up, total] = containerOutput.trim().split('\n').map(n => parseInt(n) || 0)
const down = total - up
await db.query(
`UPDATE tbl_eal_monitoring
SET details = ?, status = ?, last_check = NOW()
WHERE category = 'container' AND name = 'EasyPanel Containers'`,
[
JSON.stringify({ total, up, down, restarting: 0 }),
down > 0 ? 'warning' : 'ok'
]
)
await db.query(
`UPDATE tbl_eal_monitoring
SET details = ?, status = ?, last_check = NOW()
WHERE category = 'container' AND name = 'EasyPanel Containers'`,
[
JSON.stringify({ total, up, down, restarting: 0 }),
down > 0 ? 'warning' : 'ok'
]
)
} catch {
// Container stats are optional
}
}
success++
console.log(` ${server.monitorName}: CPU=${metrics.cpu}%, RAM=${metrics.ram}%, Disk=${metrics.disk}%`)
console.log(`[SSH] ${server.monitorName}: CPU=${metrics.cpu}%, RAM=${metrics.ram}%, Disk=${metrics.disk}%`)
} catch (error) {
console.error(` Failed to collect metrics from ${server.name}:`, error)
console.error(`[SSH] Failed ${server.name}:`, (error as Error).message)
failed++
}
}
@@ -156,62 +224,20 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
}
/**
* Sync Hetzner metrics to monitoring table
*/
export async function syncHetznerToMonitoring(): Promise<number> {
// First collect fresh Hetzner metrics
await collectHetznerMetrics()
// Then sync to monitoring table
let synced = 0
for (const [hetznerName, monitorName] of Object.entries(HETZNER_MAPPING)) {
const namePattern = hetznerName.includes('.') ? hetznerName : `${hetznerName}%`
const [result] = await db.query<any[]>(`
UPDATE tbl_eal_monitoring m
JOIN v_eal_hetzner_latest h ON h.name LIKE ?
SET m.details = JSON_OBJECT(
'cpu', ROUND(h.cpu_percent, 1),
'ram', 0,
'disk', 0,
'load', ROUND(h.cpu_percent / 25, 2),
'network_in', h.network_in_bps,
'network_out', h.network_out_bps,
'hetzner_id', h.hetzner_id
),
m.status = 'up',
m.last_check = NOW()
WHERE m.category = 'server' AND m.name = ?
`, [namePattern, monitorName])
if ((result as any).affectedRows > 0) synced++
}
return synced
}
/**
* Collect all server metrics (Hetzner + SSH)
* Collect all server metrics (SSH only - replaces Hetzner sync)
*/
export async function collectAllServerMetrics(): Promise<{
hetzner: { success: number; failed: number }
ssh: { success: number; failed: number }
synced: number
}> {
console.log('[METRICS] Starting server metrics collection...')
// Collect SSH metrics (CWP, EasyPanel)
console.log('[METRICS] Collecting server metrics via SSH...')
const ssh = await collectSSHMetrics()
console.log(`[SSH] ${ssh.success} OK, ${ssh.failed} failed`)
// Collect and sync Hetzner metrics
const synced = await syncHetznerToMonitoring()
console.log(`[HETZNER] ${synced} servers synced to monitoring`)
return {
hetzner: { success: synced, failed: 4 - synced },
ssh,
synced
}
console.log(`[METRICS] Done: ${ssh.success} OK, ${ssh.failed} failed`)
return { ssh }
}
// Keep for backward compatibility with routes
export async function syncHetznerToMonitoring(): Promise<number> {
// Now handled by SSH collection
const result = await collectSSHMetrics()
return result.success
}