diff --git a/api/server.ts b/api/server.ts index 28d1596..3b73c06 100644 --- a/api/server.ts +++ b/api/server.ts @@ -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) + } }) diff --git a/api/services/server-metrics.ts b/api/services/server-metrics.ts index e3e30e3..2ce53d4 100644 --- a/api/services/server-metrics.ts +++ b/api/services/server-metrics.ts @@ -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 = { - '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 { - // 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 { + 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 { - // 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(` - 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 { + // Now handled by SSH collection + const result = await collectSSHMetrics() + return result.success } diff --git a/package-lock.json b/package-lock.json index 7e9e94d..da5c978 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-oidc-context": "^3.1.1", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -32,6 +33,7 @@ "@types/node": "^24.10.10", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/ssh2": "^1.15.5", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.24", "concurrently": "^9.1.2", @@ -1971,6 +1973,33 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2370,6 +2399,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -2453,6 +2491,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2553,6 +2600,15 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2795,6 +2851,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4750,6 +4820,13 @@ "node": ">=8.0.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5578,6 +5655,23 @@ "node": ">= 0.6" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5760,6 +5854,12 @@ "fsevents": "~2.3.3" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 7459eb1..99f6837 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react-oidc-context": "^3.1.1", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -37,6 +38,7 @@ "@types/node": "^24.10.10", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/ssh2": "^1.15.5", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.24", "concurrently": "^9.1.2",