diff --git a/bin/lib/admin.js b/bin/lib/admin.js index defc15f..86db1cd 100644 --- a/bin/lib/admin.js +++ b/bin/lib/admin.js @@ -157,8 +157,14 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) { .sidebar h2 { font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:1px; margin:0 0 4px; color:#94a3b8; } .service-list { list-style:none; margin:0; padding:0; } .service-list li { margin:0 0 6px; } - .svc-link { display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:6px; font-size:.85rem; line-height:1.2; background:#334155; transition:background .15s ease; } + .svc-link { display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:6px; font-size:.85rem; line-height:1.2; background:#334155; transition:background .15s ease; cursor:pointer; text-decoration:none; color:#e2e8f0; } .svc-link:hover { background:#475569; } + .svc-link.active { background:#0369a1; } + .nav-section { margin-bottom:20px; } + .nav-link { display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:6px; font-size:.85rem; line-height:1.2; background:#334155; transition:background .15s ease; cursor:pointer; text-decoration:none; color:#e2e8f0; margin-bottom:6px; } + .nav-link:hover { background:#475569; } + .nav-link.active { background:#0369a1; font-weight:600; } + .nav-icon { font-size:1rem; } .dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; box-shadow:0 0 0 2px rgba(0,0,0,0.15) inset; } .dot.up { background:#0d9488; } .dot.down { background:#dc2626; } @@ -218,11 +224,15 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) { .dot.starting { background:#f59e0b; } .dot.external { background:#8b5cf6; } + .page-container { display:none; } + .page-container.active { display:block; } + @media (max-width: 920px) { .layout { flex-direction:column; } .sidebar { width:100%; flex-direction:row; flex-wrap:wrap; } .service-list { display:flex; flex-wrap:wrap; gap:8px; } .service-list li { margin:0; } } + +
@@ -771,18 +1116,29 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
@@ -791,6 +1147,9 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
Service Overview
Next refresh in ${(refreshInterval/1000).toFixed(1)}s
+ + +
${servicesWithStatus.length === 0 ? `
No services found. Run inside a generated polyglot workspace.
` : ` @@ -871,6 +1230,58 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) { + + + + +
+

Resource Monitoring

+ + +
+
+

System Resource Usage

+
+
+

CPU Usage

+
+ +
+
+
+

Memory Usage

+
+ +
+
+
+
+
+

Network I/O

+
+ +
+
+
+

Disk Usage

+
+ +
+
+
+
+
+ + +
+
+

Loading per-service resource metrics...

+
+
+ + +
+ @@ -907,6 +1318,77 @@ export async function startAdminDashboard(options = {}) { console.log(chalk.yellow(' Press Ctrl+C to stop\n')); // Initialize log file watcher +// Helper function to get system metrics +async function getSystemMetrics() { + try { + // Import systeminformation dynamically to avoid dependency issues in tests + const si = await import('systeminformation'); + + const [cpuInfo, cpuLoad, memory, networkStats, disk] = await Promise.all([ + si.cpu().catch(() => ({})), + si.currentLoad().catch(() => ({ currentLoad: 0 })), + si.mem().catch(() => ({})), + si.networkStats().catch(() => ([])), + si.fsSize().catch(() => ([])) + ]); + + // Calculate network totals + const networkTotals = networkStats.reduce((totals, iface) => { + if (iface.iface && !iface.iface.startsWith('lo')) { // Skip loopback + totals.rx_bytes += iface.rx_bytes || 0; + totals.tx_bytes += iface.tx_bytes || 0; + totals.rx_sec += iface.rx_sec || 0; + totals.tx_sec += iface.tx_sec || 0; + } + return totals; + }, { rx_bytes: 0, tx_bytes: 0, rx_sec: 0, tx_sec: 0 }); + + // Calculate disk totals - only use root volume to avoid duplicates + const rootVolume = disk.find(volume => volume.mount === '/') || disk[0] || {}; + const diskTotals = { + total: rootVolume.size || 0, + used: rootVolume.used || 0, + available: rootVolume.available || 0 + }; + + return { + cpu: { + cores: cpuInfo.cores || 0, + model: cpuInfo.model || 'Unknown', + speed: cpuInfo.speed || 0, + percent: parseFloat((cpuLoad.currentLoad || 0).toFixed(1)) + }, + memory: { + total: memory.total || 0, + used: memory.active || memory.used || 0, + available: memory.available || 0, + percent: memory.total ? (((memory.active || memory.used || 0) / memory.total) * 100) : 0 + }, + network: { + interfaces: networkStats.map(iface => iface.iface).filter(name => name && !name.startsWith('lo')), + total_rx: networkTotals.rx_bytes, + total_tx: networkTotals.tx_bytes, + rx_sec: networkTotals.rx_sec, + tx_sec: networkTotals.tx_sec + }, + disk: { + total: diskTotals.total, + used: diskTotals.used, + available: diskTotals.available, + percent: diskTotals.total ? ((diskTotals.used / diskTotals.total) * 100) : 0 + } + }; + } catch (error) { + console.warn('Failed to get system metrics:', error.message); + return { + cpu: { cores: 0, model: 'Unknown', speed: 0, percent: 0 }, + memory: { total: 0, used: 0, available: 0, percent: 0 }, + network: { interfaces: [], total_rx: 0, total_tx: 0, rx_sec: 0, tx_sec: 0 }, + disk: { total: 0, used: 0, available: 0, percent: 0 } + }; + } +} + globalLogWatcher = new LogFileWatcher(cwd); try { await globalLogWatcher.startWatching(); @@ -972,6 +1454,40 @@ export async function startAdminDashboard(options = {}) { return; } + if (url.pathname === '/api/metrics') { + // API endpoint for system and service metrics + try { + const metrics = await getSystemMetrics(); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }); + res.end(JSON.stringify({ + metrics: { + timestamp: new Date().toISOString(), + cpu: metrics.cpu || {}, + memory: metrics.memory || {}, + network: metrics.network || {}, + disk: metrics.disk || {} + }, + systemInfo: { + cpu: { cores: metrics.cpu?.cores || 0, model: metrics.cpu?.model || 'Unknown' }, + memory: { total: metrics.memory?.total || 0 }, + disk: { total: metrics.disk?.total || 0, available: metrics.disk?.available || 0 }, + network: { interfaces: metrics.network?.interfaces || [] } + } + }, null, 2)); + } catch (e) { + console.error('โŒ Metrics API error:', e.message); + res.writeHead(500, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }); + res.end(JSON.stringify({ error: e.message })); + } + return; + } + // Service management endpoints if (url.pathname === '/api/services/start' && req.method === 'POST') { try { @@ -1196,4 +1712,4 @@ export async function startAdminDashboard(options = {}) { globalLogWatcher.addListener((event, data) => broadcastLogEvent(event, data)); } return server; -} \ No newline at end of file +} diff --git a/bin/lib/resources.js b/bin/lib/resources.js new file mode 100644 index 0000000..b287e9d --- /dev/null +++ b/bin/lib/resources.js @@ -0,0 +1,482 @@ +import pidusage from 'pidusage'; +import si from 'systeminformation'; +import { EventEmitter } from 'events'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * Resource monitoring class for collecting system metrics per service + */ +export class ResourceMonitor extends EventEmitter { + constructor(options = {}) { + super(); + this.collectInterval = options.collectInterval || 5000; // 5 seconds default + this.maxHistorySize = options.maxHistorySize || 720; // 1 hour at 5s intervals + this.isCollecting = false; + this.intervalId = null; + this.currentServices = []; // Store current services being monitored + + // In-memory storage for metrics history + this.metricsHistory = new Map(); // service name -> array of metrics + + // Cache for system-wide info + this.systemInfo = { + cpu: { cores: 0, model: '' }, + memory: { total: 0 }, + disk: { total: 0, available: 0 }, + network: { interfaces: [] } + }; + + this.lastNetworkStats = new Map(); + } + + /** + * Initialize the resource monitor + */ + async initialize() { + console.log('๐Ÿ”ง ResourceMonitor.initialize() called'); + try { + console.log('๐Ÿ”ง Getting system information...'); + // Get basic system information + const [cpu, memory, disk, networkInterfaces] = await Promise.all([ + si.cpu(), + si.mem(), + si.fsSize(), + si.networkInterfaces() + ]); + + console.log('๐Ÿ”ง System info collected:', { + cores: cpu.cores, + memory: `${(memory.total / 1024 / 1024 / 1024).toFixed(1)}GB` + }); + + this.systemInfo = { + cpu: { cores: cpu.cores, model: cpu.model }, + memory: { total: memory.total }, + disk: { + total: disk.reduce((acc, d) => acc + d.size, 0), + available: disk.reduce((acc, d) => acc + d.available, 0) + }, + network: { + interfaces: networkInterfaces.filter(iface => + !iface.internal && iface.operstate === 'up' + ).map(iface => ({ name: iface.iface, type: iface.type })) + } + }; + + console.log('๐Ÿ” Resource monitor initialized'); + return this.systemInfo; + } catch (error) { + console.warn('โš ๏ธ Failed to initialize resource monitor:', error.message); + throw error; + } + } + + /** + * Find PIDs for services by process name and port + */ + async findServicePids(services) { + const servicesWithPids = []; + + for (const service of services) { + let pid = service.pid; + + // If no PID provided, try to find it by process name and port + if (!pid) { + try { + // Try to find Node.js processes on the service port + if (service.type === 'node' || service.type === 'frontend') { + const { stdout } = await execAsync(`lsof -t -i:${service.port} 2>/dev/null || echo ""`); + const pids = stdout.trim().split('\n').filter(p => p && p !== ''); + if (pids.length > 0) { + pid = parseInt(pids[0]); // Take the first PID + } + } + } catch (error) { + // Ignore errors, pid will remain null + } + } + + servicesWithPids.push({ ...service, pid }); + } + + return servicesWithPids; + } + + /** + * Start collecting metrics for services + */ + async startCollecting(services = []) { + if (this.isCollecting) { + console.log('Resource monitoring already running'); + return; + } + + this.isCollecting = true; + console.log(`๐Ÿ“Š Starting resource monitoring for ${services.length} services`); + + // Find actual PIDs for services + this.currentServices = await this.findServicePids(services); + + // Collect metrics immediately, then on interval + this.collectMetrics(this.currentServices); + this.intervalId = setInterval(async () => { + // Re-detect PIDs on each collection in case services restart + this.currentServices = await this.findServicePids(this.currentServices); + this.collectMetrics(this.currentServices); + }, this.collectInterval); + } + + /** + * Update the services being monitored (e.g., when PIDs change) + */ + async updateServices(services = []) { + this.currentServices = await this.findServicePids(services); // Find PIDs for updated services + } + + /** + * Stop collecting metrics + */ + stopCollecting() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.isCollecting = false; + this.currentServices = []; + console.log('๐Ÿ“Š Resource monitoring stopped'); + } + + /** + * Collect metrics for all services + */ + async collectMetrics(services) { + const timestamp = new Date(); + const metricsCollection = []; + + console.log(`๐Ÿ“Š Starting metrics collection for ${services.length} services at ${timestamp}`); + + try { + // Get system-wide metrics + const [systemCpu, systemMemory, networkStats] = await Promise.all([ + si.currentLoad(), + si.mem(), + si.networkStats() + ]); + + console.log('๐Ÿ“Š System metrics collected:', { + cpu: systemCpu.currentload, + memory: `${(systemMemory.used / 1024 / 1024 / 1024).toFixed(1)}GB / ${(systemMemory.total / 1024 / 1024 / 1024).toFixed(1)}GB` + }); + + // Process each service + for (const service of services) { + try { + const serviceMetrics = await this.collectServiceMetrics( + service, + systemCpu, + systemMemory, + networkStats, + timestamp + ); + + if (serviceMetrics) { + metricsCollection.push(serviceMetrics); + this.storeMetrics(service.name, serviceMetrics); + console.log(`๐Ÿ“Š Stored metrics for ${service.name} in history`); + } + } catch (error) { + // Don't fail entire collection if one service fails + console.debug(`โŒ Failed to collect metrics for ${service.name}:`, error.message); + } + } + + // Emit metrics update event + if (metricsCollection.length > 0) { + console.log(`๐Ÿ“Š Emitting metrics update with ${metricsCollection.length} services`); + this.emit('metricsUpdate', { + timestamp, + services: metricsCollection, + system: { + cpu: systemCpu.currentload, + memory: { + used: systemMemory.used, + total: systemMemory.total, + percentage: (systemMemory.used / systemMemory.total) * 100 + } + } + }); + } else { + console.log('โš ๏ธ No metrics collected - empty metricsCollection'); + } + + } catch (error) { + console.error('โŒ Error collecting system metrics:', error.message); + } + } + + /** + * Collect metrics for a specific service + */ + async collectServiceMetrics(service, systemCpu, systemMemory, networkStats, timestamp) { + const { name, type, port, pid } = service; + + console.log(`๐Ÿ” Collecting metrics for ${name}: pid=${pid}, type=${type}`); + + if (!pid) { + console.log(`โš ๏ธ No PID for service ${name}, returning stopped status`); + return { + serviceName: name, + type, + port, + timestamp, + status: 'stopped', + cpu: { usage: 0 }, + memory: { usage: 0, percentage: 0 }, + disk: { read: 0, write: 0 }, + network: { rx: 0, tx: 0 } + }; + } + + try { + // Get process-specific metrics using pidusage + console.log(`๐Ÿ“Š Getting pidusage for PID ${pid}`); + const processStats = await pidusage(pid); + console.log(`๐Ÿ“Š Process stats for ${name}:`, { cpu: processStats.cpu, memory: processStats.memory }); + + // Calculate network metrics for the service (approximation) + const networkMetrics = this.calculateNetworkMetrics(service, networkStats); + + // Get disk I/O if available (Linux/macOS) + const diskMetrics = await this.getDiskMetrics(pid); + + const result = { + serviceName: name, + type, + port, + pid, + timestamp, + status: 'running', + cpu: { + usage: processStats.cpu, // CPU percentage + time: processStats.ctime // CPU time in ms + }, + memory: { + usage: processStats.memory, // Memory in bytes + percentage: (processStats.memory / systemMemory.total) * 100 + }, + disk: diskMetrics, + network: networkMetrics, + uptime: Date.now() - processStats.elapsed // Process uptime in ms + }; + + console.log(`โœ… Collected metrics for ${name}:`, { + cpu: result.cpu.usage, + memory: result.memory.percentage, + status: result.status + }); + + return result; } catch (error) { + // Process might have stopped + return { + serviceName: name, + type, + port, + timestamp, + status: 'error', + error: error.message, + cpu: { usage: 0 }, + memory: { usage: 0, percentage: 0 }, + disk: { read: 0, write: 0 }, + network: { rx: 0, tx: 0 } + }; + } + } + + /** + * Calculate network metrics (approximation based on port usage) + */ + calculateNetworkMetrics(service, networkStats) { + const { port } = service; + + // This is an approximation - in a real implementation, you might want to + // use more sophisticated methods to track per-process network usage + let totalRx = 0; + let totalTx = 0; + + if (networkStats && networkStats.length > 0) { + // Sum up network stats from all active interfaces + networkStats.forEach(stat => { + totalRx += stat.rx_bytes || 0; + totalTx += stat.tx_bytes || 0; + }); + } + + const key = `${service.name}_${port}`; + const lastStats = this.lastNetworkStats.get(key); + + let rxRate = 0; + let txRate = 0; + + if (lastStats) { + const timeDiff = (Date.now() - lastStats.timestamp) / 1000; // seconds + rxRate = Math.max(0, (totalRx - lastStats.rx) / timeDiff); + txRate = Math.max(0, (totalTx - lastStats.tx) / timeDiff); + } + + this.lastNetworkStats.set(key, { + rx: totalRx, + tx: totalTx, + timestamp: Date.now() + }); + + return { + rx: rxRate, // bytes per second received + tx: txRate // bytes per second transmitted + }; + } + + /** + * Get disk I/O metrics for a process (platform-dependent) + */ + async getDiskMetrics(pid) { + try { + // For macOS and Linux, try to get disk stats + if (process.platform === 'darwin') { + // macOS - use ps command to get some basic info + const { stdout } = await execAsync(`ps -o pid,rss -p ${pid}`); + return { read: 0, write: 0 }; // Disk I/O is harder to get on macOS + } else if (process.platform === 'linux') { + // Linux - read from /proc/[pid]/io + try { + const { stdout } = await execAsync(`cat /proc/${pid}/io`); + const lines = stdout.split('\n'); + let readBytes = 0; + let writeBytes = 0; + + lines.forEach(line => { + if (line.startsWith('read_bytes:')) { + readBytes = parseInt(line.split(':')[1].trim()); + } else if (line.startsWith('write_bytes:')) { + writeBytes = parseInt(line.split(':')[1].trim()); + } + }); + + return { read: readBytes, write: writeBytes }; + } catch (e) { + return { read: 0, write: 0 }; + } + } else { + // Windows or other platforms + return { read: 0, write: 0 }; + } + } catch (error) { + return { read: 0, write: 0 }; + } + } + + /** + * Store metrics in history with size limit + */ + storeMetrics(serviceName, metrics) { + if (!this.metricsHistory.has(serviceName)) { + this.metricsHistory.set(serviceName, []); + } + + const history = this.metricsHistory.get(serviceName); + history.push(metrics); + + // Keep only the last N metrics + if (history.length > this.maxHistorySize) { + history.splice(0, history.length - this.maxHistorySize); + } + } + + /** + * Get historical metrics for a service + */ + getMetricsHistory(serviceName, options = {}) { + const { since, limit } = options; + let history = this.metricsHistory.get(serviceName) || []; + + if (since) { + const sinceTime = new Date(since); + history = history.filter(metric => new Date(metric.timestamp) >= sinceTime); + } + + if (limit) { + history = history.slice(-limit); + } + + return history; + } + + /** + * Get current metrics for all services + */ + getCurrentMetrics() { + console.log('๐Ÿ“Š getCurrentMetrics called'); + console.log('๐Ÿ“Š metricsHistory size:', this.metricsHistory.size); + + const result = {}; + for (const [serviceName, history] of this.metricsHistory.entries()) { + const latest = history[history.length - 1]; + if (latest) { + result[serviceName] = latest; + } + console.log(`๐Ÿ“Š Service ${serviceName}: ${history.length} entries, latest:`, latest ? latest.status : 'none'); + } + + console.log('๐Ÿ“Š Returning metrics for services:', Object.keys(result)); + return result; + } + + /** + * Clear metrics history for a service or all services + */ + clearMetricsHistory(serviceName = null) { + if (serviceName) { + this.metricsHistory.delete(serviceName); + } else { + this.metricsHistory.clear(); + } + } + + /** + * Get system information + */ + getSystemInfo() { + return this.systemInfo; + } + + /** + * Format bytes for human reading + */ + static formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + /** + * Format percentage for display + */ + static formatPercentage(value, decimals = 1) { + return `${value.toFixed(decimals)}%`; + } +} + +// Export singleton instance +let resourceMonitor = null; + +export function getResourceMonitor(options = {}) { + if (!resourceMonitor) { + resourceMonitor = new ResourceMonitor(options); + } + return resourceMonitor; +} \ No newline at end of file diff --git a/bin/lib/scaffold.js b/bin/lib/scaffold.js index 0c7129f..0f5aa01 100644 --- a/bin/lib/scaffold.js +++ b/bin/lib/scaffold.js @@ -436,7 +436,7 @@ export async function scaffoldMonorepo(projectNameArg, options) { await fs.mkdirp(scriptsDir); // Create list-services script with runtime status detection const listScriptPath = path.join(scriptsDir, 'list-services.mjs'); - await fs.writeFile(listScriptPath, `#!/usr/bin/env node\nimport fs from 'fs';\nimport path from 'path';\nimport net from 'net';\nimport chalk from 'chalk';\nconst cwd = process.cwd();\nconst cfgPath = path.join(cwd, 'polyglot.json');\nif(!fs.existsSync(cfgPath)){ console.error(chalk.red('polyglot.json not found.')); process.exit(1);}\nconst cfg = JSON.parse(fs.readFileSync(cfgPath,'utf-8'));\n\nfunction strip(str){return str.replace(/\\x1B\\[[0-9;]*m/g,'');}\nfunction pad(str,w){const raw=strip(str);return str+' '.repeat(Math.max(0,w-raw.length));}\nfunction table(items){ if(!items.length){console.log(chalk.yellow('No services.'));return;} const cols=[{k:'name',h:'Name'},{k:'type',h:'Type'},{k:'port',h:'Port'},{k:'status',h:'Status'},{k:'path',h:'Path'}]; const widths=cols.map(c=>Math.max(c.h.length,...items.map(i=>strip(i[c.k]).length))+2); const top='โ”Œ'+widths.map(w=>'โ”€'.repeat(w)).join('โ”ฌ')+'โ”'; const sep='โ”œ'+widths.map(w=>'โ”€'.repeat(w)).join('โ”ผ')+'โ”ค'; const bot='โ””'+widths.map(w=>'โ”€'.repeat(w)).join('โ”ด')+'โ”˜'; console.log(top); console.log('โ”‚'+cols.map((c,i)=>pad(chalk.bold.white(c.h),widths[i])).join('โ”‚')+'โ”‚'); console.log(sep); for(const it of items){ console.log('โ”‚'+cols.map((c,i)=>pad(it[c.k],widths[i])).join('โ”‚')+'โ”‚'); } console.log(bot); console.log(chalk.gray('Total: '+items.length)); }\n\nasync function check(port){ return new Promise(res=>{ const sock=net.createConnection({port,host:'127.0.0.1'},()=>{sock.destroy();res(true);}); sock.setTimeout(350,()=>{sock.destroy();res(false);}); sock.on('error',()=>{res(false);});}); }\nconst promises = cfg.services.map(async s=>{ const up = await check(s.port); return { ...s, _up: up }; });\nconst results = await Promise.all(promises);\nconst rows = results.map(s=>({ name: chalk.cyan(s.name), type: colorType(s.type)(s.type), port: chalk.green(String(s.port)), status: s._up ? chalk.bgGreen.black(' UP ') : chalk.bgRed.white(' DOWN '), path: chalk.dim(s.path) }));\nfunction colorType(t){ switch(t){case 'node': return chalk.green; case 'python': return chalk.yellow; case 'go': return chalk.cyan; case 'java': return chalk.red; case 'frontend': return chalk.blue; default: return chalk.white;} }\nif(process.argv.includes('--json')) { console.log(JSON.stringify(results.map(r=>({name:r.name,type:r.type,port:r.port,up:r._up,path:r.path})),null,2)); } else { console.log(chalk.magentaBright('\nWorkspace Services (runtime status)')); table(rows); }\n`); + await fs.writeFile(listScriptPath, `#!/usr/bin/env node\nimport fs from 'fs';\nimport path from 'path';\nimport net from 'net';\nimport chalk from 'chalk';\nconst cwd = process.cwd();\nconst cfgPath = path.join(cwd, 'polyglot.json');\nif(!fs.existsSync(cfgPath)){ console.error(chalk.red('polyglot.json not found.')); process.exit(1);}\nconst cfg = JSON.parse(fs.readFileSync(cfgPath,'utf-8'));\n\nfunction strip(str){return str.replace(/\\x1B\\[[0-9;]*m/g,'');}\nfunction pad(str,w){const raw=strip(str);return str+' '.repeat(Math.max(0,w-raw.length));}\nfunction table(items){ if(!items.length){console.log(chalk.yellow('No services.'));return;} const cols=[{k:'name',h:'Name'},{k:'type',h:'Type'},{k:'port',h:'Port'},{k:'status',h:'Status'},{k:'path',h:'Path'}]; const widths=cols.map(c=>Math.max(c.h.length,...items.map(i=>strip(i[c.k]).length))+2); const top='โ”Œ'+widths.map(w=>'โ”€'.repeat(w)).join('โ”ฌ')+'โ”'; const sep='โ”œ'+widths.map(w=>'โ”€'.repeat(w)).join('โ”ผ')+'โ”ค'; const bot='โ””'+widths.map(w=>'โ”€'.repeat(w)).join('โ”ด')+'โ”˜'; console.log(top); console.log('โ”‚'+cols.map((c,i)=>pad(chalk.bold.white(c.h),widths[i])).join('โ”‚')+'โ”‚'); console.log(sep); for(const it of items){ console.log('โ”‚'+cols.map((c,i)=>pad(it[c.k],widths[i])).join('โ”‚')+'โ”‚'); } console.log(bot); console.log(chalk.gray('Total: '+items.length)); }\n\nasync function check(port){ return new Promise(res=>{ const sock=net.createConnection({port,host:'127.0.0.1'},()=>{sock.destroy();res(true);}); sock.setTimeout(350,()=>{sock.destroy();res(false);}); sock.on('error',()=>{res(false);});}); }\nconst promises = cfg.services.map(async s=>{ const up = await check(s.port); return { ...s, _up: up }; });\nconst results = await Promise.all(promises);\nconst rows = results.map(s=>({ name: chalk.cyan(s.name), type: colorType(s.type)(s.type), port: chalk.green(String(s.port)), status: s._up ? chalk.bgGreen.black(' UP ') : chalk.bgRed.white(' DOWN '), path: chalk.dim(s.path) }));\nfunction colorType(t){ switch(t){case 'node': return chalk.green; case 'python': return chalk.yellow; case 'go': return chalk.cyan; case 'java': return chalk.red; case 'frontend': return chalk.blue; default: return chalk.white;} }\nif(process.argv.includes('--json')) { console.log(JSON.stringify(results.map(r=>({name:r.name,type:r.type,port:r.port,up:r._up,path:r.path})),null,2)); } else { console.log(chalk.magentaBright('\\nWorkspace Services (runtime status)')); table(rows); }\n`); const readmePath = path.join(projectDir, 'README.md'); const svcList = services.map(s => `- ${s.name} (${s.type}) port:${s.port}`).join('\n'); diff --git a/package-lock.json b/package-lock.json index 8323d9e..dd5d7ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "fs-extra": "^11.3.2", "hookable": "^5.5.3", "lodash": "^4.17.21", + "pidusage": "^4.0.1", "prompts": "^2.4.2", + "systeminformation": "^5.27.11", "ws": "^8.16.0" }, "bin": { @@ -2380,6 +2382,18 @@ "dev": true, "license": "ISC" }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "dev": true, @@ -2561,6 +2575,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/search-insights": { "version": "2.17.3", "dev": true, @@ -2698,6 +2732,32 @@ "node": ">=16" } }, + "node_modules/systeminformation": { + "version": "5.27.11", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.11.tgz", + "integrity": "sha512-K3Lto/2m3K2twmKHdgx5B+0in9qhXK4YnoT9rIlgwN/4v7OV5c8IjbeAUkuky/6VzCQC7iKCAqi8rZathCdjHg==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tabbable": { "version": "6.2.0", "dev": true, diff --git a/package.json b/package.json index 9eed7e4..fe1809a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,9 @@ "fs-extra": "^11.3.2", "hookable": "^5.5.3", "lodash": "^4.17.21", + "pidusage": "^4.0.1", "prompts": "^2.4.2", + "systeminformation": "^5.27.11", "ws": "^8.16.0" }, "devDependencies": { diff --git a/tests/resource-monitoring-integration.test.js b/tests/resource-monitoring-integration.test.js new file mode 100644 index 0000000..b532982 --- /dev/null +++ b/tests/resource-monitoring-integration.test.js @@ -0,0 +1,303 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execa } from 'execa'; +import fs from 'fs'; +import path from 'path'; +import http from 'http'; + +describe('Resource Monitoring Integration', () => { + const testWorkspace = path.join(process.cwd(), 'test-workspace', 'integration-test'); + let adminProcess = null; + + beforeEach(async () => { + // Clean up previous test workspace + if (fs.existsSync(testWorkspace)) { + fs.rmSync(testWorkspace, { recursive: true, force: true }); + } + + // Create test workspace + fs.mkdirSync(testWorkspace, { recursive: true }); + + // Create a simple polyglot project + const polyglotConfig = { + name: 'integration-test', + services: [ + { name: 'test-node', type: 'node', port: 4001, path: 'services/test-node' } + ] + }; + + fs.writeFileSync( + path.join(testWorkspace, 'polyglot.json'), + JSON.stringify(polyglotConfig, null, 2) + ); + + // Create service directory structure + const serviceDir = path.join(testWorkspace, 'services', 'test-node'); + fs.mkdirSync(serviceDir, { recursive: true }); + + // Create basic package.json for the service + const packageJson = { + name: 'test-node', + version: '1.0.0', + scripts: { + dev: 'node index.js', + start: 'node index.js' + }, + dependencies: {} + }; + + fs.writeFileSync( + path.join(serviceDir, 'package.json'), + JSON.stringify(packageJson, null, 2) + ); + + // Create basic service file + const serviceCode = ` +const http = require('http'); +const port = process.env.PORT || 4001; + +const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() })); + } else { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Test Node Service Running'); + } +}); + +server.listen(port, () => { + console.log(\`Test service running on port \${port}\`); +}); + +process.on('SIGTERM', () => { + console.log('Received SIGTERM, shutting down gracefully'); + server.close(() => { + process.exit(0); + }); +}); +`; + + fs.writeFileSync(path.join(serviceDir, 'index.js'), serviceCode); + }); + + afterEach(async () => { + // Stop admin dashboard if running + if (adminProcess && !adminProcess.killed) { + try { + adminProcess.kill('SIGTERM'); + // Give the process time to shut down gracefully + await Promise.race([ + adminProcess.catch(() => {}), // Ignore errors during cleanup + new Promise(resolve => setTimeout(resolve, 2000)) + ]); + } catch (error) { + // Force kill if graceful shutdown fails + try { + adminProcess.kill('SIGKILL'); + } catch (e) { + // Ignore errors during force kill + } + } + } + adminProcess = null; + + // Clean up test workspace + if (fs.existsSync(testWorkspace)) { + fs.rmSync(testWorkspace, { recursive: true, force: true }); + } + }); + + it('should start admin dashboard with resource monitoring enabled', async () => { + // Start admin dashboard + adminProcess = execa('node', [ + path.join(process.cwd(), 'bin', 'index.js'), + 'admin', + '--port', '9999', + '--no-open' + ], { + cwd: testWorkspace, + stdio: 'pipe' + }); + + // Wait for dashboard to start + await new Promise(resolve => setTimeout(resolve, 3000)); + + expect(adminProcess.killed).toBe(false); + + // Test if dashboard is responding + const response = await makeRequest('GET', 'http://localhost:9999/', {}, 5000); + expect(response.statusCode).toBe(200); + expect(response.body).toContain('Polyglot Admin Dashboard'); + + // Test if resource monitoring UI is included + expect(response.body).toContain('Resource Monitoring'); + expect(response.body).toContain('CPU Usage'); + expect(response.body).toContain('Memory Usage'); + expect(response.body).toContain('Network I/O'); + expect(response.body).toContain('Disk Usage'); + }, 15000); + + it('should provide metrics API endpoint', async () => { + // Start admin dashboard + adminProcess = execa('node', [ + path.join(process.cwd(), 'bin', 'index.js'), + 'admin', + '--port', '9998', + '--no-open' + ], { + cwd: testWorkspace, + stdio: 'pipe' + }); + + // Wait for dashboard to start + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Test metrics API endpoint + const response = await makeRequest('GET', 'http://localhost:9998/api/metrics'); + expect(response.statusCode).toBe(200); + + const data = JSON.parse(response.body); + expect(data).toHaveProperty('metrics'); + expect(data).toHaveProperty('systemInfo'); + + if (data.systemInfo) { + expect(data.systemInfo).toHaveProperty('cpu'); + expect(data.systemInfo).toHaveProperty('memory'); + } + }, 15000); + + it('should provide service status API with resource monitoring integration', async () => { + // Start admin dashboard + adminProcess = execa('node', [ + path.join(process.cwd(), 'bin', 'index.js'), + 'admin', + '--port', '9997', + '--no-open' + ], { + cwd: testWorkspace, + stdio: 'pipe' + }); + + // Wait for dashboard to start + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Test service status API + const response = await makeRequest('GET', 'http://localhost:9997/api/status'); + expect(response.statusCode).toBe(200); + + const services = JSON.parse(response.body); + expect(Array.isArray(services)).toBe(true); + expect(services.length).toBeGreaterThan(0); + + const service = services[0]; + expect(service).toHaveProperty('name'); + expect(service).toHaveProperty('type'); + expect(service).toHaveProperty('port'); + expect(service).toHaveProperty('status'); + }, 15000); + + it('should handle graceful shutdown without errors', async () => { + // Start admin dashboard + adminProcess = execa('node', [ + path.join(process.cwd(), 'bin', 'index.js'), + 'admin', + '--port', '9996', + '--no-open' + ], { + cwd: testWorkspace, + stdio: 'pipe' + }); + + // Wait for dashboard to start + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Send SIGTERM to gracefully shut down + adminProcess.kill('SIGTERM'); + + // Wait for process to exit with timeout + try { + const result = await Promise.race([ + adminProcess, + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10000)) + ]); + + // Process should exit gracefully (exit code 0) + expect(result.exitCode).toBe(0); + } catch (error) { + // Only accept SIGTERM as a valid termination signal + expect(error.signal).toBe('SIGTERM'); + } + }, 15000); + + it('should include Chart.js library for metrics visualization', async () => { + // Start admin dashboard + adminProcess = execa('node', [ + path.join(process.cwd(), 'bin', 'index.js'), + 'admin', + '--port', '9995', + '--no-open' + ], { + cwd: testWorkspace, + stdio: 'pipe' + }); + + // Wait for dashboard to start + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Test if Chart.js is included in the HTML + const response = await makeRequest('GET', 'http://localhost:9995/'); + expect(response.statusCode).toBe(200); + expect(response.body).toContain('chart.js'); + expect(response.body).toMatch(/]*\bid\s*=\s*["']cpu-chart["'][^>]*>/i); + expect(response.body).toMatch(/]*\bid\s*=\s*["']memory-chart["'][^>]*>/i); + expect(response.body).toMatch(/]*\bid\s*=\s*["']network-chart["'][^>]*>/i); + expect(response.body).toMatch(/]*\bid\s*=\s*["']disk-chart["'][^>]*>/i); + }, 15000); +}); + +// Helper function to make HTTP requests with timeout +function makeRequest(method, url, data = null, timeout = 10000) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const options = { + hostname: urlObj.hostname, + port: urlObj.port, + path: urlObj.pathname + urlObj.search, + method: method, + timeout: timeout, + headers: { + 'User-Agent': 'test-client', + } + }; + + if (data && method !== 'GET') { + const postData = typeof data === 'string' ? data : JSON.stringify(data); + options.headers['Content-Type'] = 'application/json'; + options.headers['Content-Length'] = Buffer.byteLength(postData); + } + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => body += chunk); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: body + }); + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error(`Request timeout after ${timeout}ms`)); + }); + + if (data && method !== 'GET') { + req.write(typeof data === 'string' ? data : JSON.stringify(data)); + } + + req.end(); + }); +} \ No newline at end of file