diff --git a/public/js/app.js b/public/js/app.js index 88d7d74..ca71310 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -471,14 +471,25 @@ dom.detailUptime.textContent = `${days}天 ${hours}小时 ${mins}分`; // Define metrics to show + const cpuValueHtml = ` +
+ ${formatPercent(data.cpuBusy)} +
+ Sys: ${data.cpuSystem.toFixed(1)}% + User: ${data.cpuUser.toFixed(1)}% + Wait: ${data.cpuIowait.toFixed(1)}% +
+
+ `; + const metrics = [ - { key: 'cpuBusy', label: 'CPU 忙碌率 (Busy)', value: formatPercent(data.cpuBusy) }, - { key: 'sysLoad', label: '系统负载 (Load)', value: data.sysLoad.toFixed(1) + '%' }, - { key: 'memUsedPct', label: '内存使用率 (RAM)', value: formatPercent(data.memUsedPct) }, - { key: 'swapUsedPct', label: 'SWAP 使用率', value: formatPercent(data.swapUsedPct) }, - { key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(data.rootFsUsedPct) }, - { key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(data.netRx) }, - { key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) } + { key: 'cpuBusy', label: 'CPU 使用率 (Busy Breakdown)', value: cpuValueHtml }, + { key: 'sysLoad', label: '系统负载 (Load)', value: data.sysLoad.toFixed(1) + '%' }, + { key: 'memUsedPct', label: '内存使用率 (RAM)', value: formatPercent(data.memUsedPct) }, + { key: 'swapUsedPct', label: 'SWAP 使用率', value: formatPercent(data.swapUsedPct) }, + { key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(data.rootFsUsedPct) }, + { key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(data.netRx) }, + { key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) } ]; dom.detailMetricsList.innerHTML = metrics.map(m => ` @@ -512,7 +523,7 @@ -
+
diff --git a/public/js/chart.js b/public/js/chart.js index 53b2a88..5652f78 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -278,7 +278,7 @@ class MetricChart { constructor(canvas, unit = '') { this.canvas = canvas; this.ctx = canvas.getContext('2d'); - this.data = { timestamps: [], values: [] }; + this.data = { timestamps: [], values: [], series: null }; this.unit = unit; // '%', 'B/s', etc. this.dpr = window.devicePixelRatio || 1; this.padding = { top: 10, right: 10, bottom: 20, left: 60 }; @@ -305,7 +305,7 @@ class MetricChart { } setData(data) { - this.data = data || { timestamps: [], values: [] }; + this.data = data || { timestamps: [], values: [], series: null }; this.animate(); } @@ -333,7 +333,7 @@ class MetricChart { ctx.clearRect(0, 0, w, h); - const { timestamps, values } = this.data; + const { timestamps, values, series } = this.data; if (!timestamps || timestamps.length < 2) { ctx.fillStyle = '#5a6380'; ctx.font = '11px sans-serif'; @@ -342,15 +342,21 @@ class MetricChart { return; } - // Find max with cushion - let maxVal = Math.max(...values, 0.1); - if (this.unit === '%' && maxVal <= 100) { - if (maxVal > 80) maxVal = 100; - else if (maxVal > 40) maxVal = 80; - else if (maxVal > 20) maxVal = 50; - else maxVal = 25; + // Determine Y max + let maxVal = 0; + if (series) { + // For stacked CPU, max sum should be 100 + maxVal = 100; } else { - maxVal = maxVal * 1.25; + maxVal = Math.max(...values, 0.1); + if (this.unit === '%' && maxVal <= 100) { + if (maxVal > 80) maxVal = 100; + else if (maxVal > 40) maxVal = 80; + else if (maxVal > 20) maxVal = 50; + else maxVal = 25; + } else { + maxVal = maxVal * 1.25; + } } const len = timestamps.length; @@ -382,41 +388,102 @@ class MetricChart { ctx.fillText(label, p.left - 8, y + 3); } - // Path - ctx.beginPath(); - ctx.moveTo(getX(0), getY(values[0])); - for (let i = 1; i < len; i++) { - const prevX = getX(i - 1); - const currX = getX(i); - const prevY = getY(values[i - 1]); - const currY = getY(values[i]); - const midX = (prevX + currX) / 2; - ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); - } - - // Stroke - ctx.strokeStyle = '#6366f1'; - ctx.lineWidth = 2; - ctx.lineJoin = 'round'; - ctx.stroke(); + if (series) { + // Draw Stacked Area + const modes = [ + { name: 'idle', color: 'rgba(100, 116, 139, 0.4)', stroke: '#64748b' }, // Slate + { name: 'other', color: 'rgba(168, 85, 247, 0.5)', stroke: '#a855f7' }, // Purple + { name: 'irq', color: 'rgba(234, 179, 8, 0.5)', stroke: '#eab308' }, // Yellow + { name: 'iowait', color: 'rgba(239, 68, 68, 0.5)', stroke: '#ef4444' }, // Red + { name: 'system', color: 'rgba(6, 182, 212, 0.5)', stroke: '#06b6d4' }, // Cyan + { name: 'user', color: 'rgba(99, 102, 241, 0.5)', stroke: '#6366f1' } // Indigo + ]; - // Fill - ctx.lineTo(getX(len - 1), p.top + chartH); - ctx.lineTo(getX(0), p.top + chartH); - ctx.closePath(); - const grad = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); - grad.addColorStop(0, 'rgba(99, 102, 241, 0.15)'); - grad.addColorStop(1, 'rgba(99, 102, 241, 0)'); - ctx.fillStyle = grad; - ctx.fill(); - - // Last point pulse - const lastX = getX(len - 1); - const lastY = getY(values[len - 1]); - ctx.beginPath(); - ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); - ctx.fillStyle = '#6366f1'; - ctx.fill(); + let currentBase = new Array(len).fill(0); + + modes.forEach(mode => { + const vals = series[mode.name]; + if (!vals) return; + + ctx.beginPath(); + ctx.moveTo(getX(0), getY(currentBase[0] + vals[0])); + for (let i = 1; i < len; i++) { + ctx.lineTo(getX(i), getY(currentBase[i] + vals[i])); + } + ctx.lineTo(getX(len - 1), getY(currentBase[len - 1])); + for (let i = len - 1; i >= 0; i--) { + ctx.lineTo(getX(i), getY(currentBase[i])); + } + ctx.closePath(); + ctx.fillStyle = mode.color; + ctx.fill(); + + // Stroke + ctx.beginPath(); + ctx.moveTo(getX(0), getY(currentBase[0] + vals[0])); + for (let i = 1; i < len; i++) { + ctx.lineTo(getX(i), getY(currentBase[i] + vals[i])); + } + ctx.strokeStyle = mode.stroke; + ctx.lineWidth = 1; + ctx.stroke(); + + // Update base + for (let i = 0; i < len; i++) { + currentBase[i] += vals[i]; + } + }); + + // Add Legend at bottom right + ctx.font = '9px sans-serif'; + ctx.textAlign = 'right'; + let lx = w - 10; + let ly = h - 5; + [...modes].reverse().forEach(m => { + ctx.fillStyle = m.stroke; + ctx.fillRect(lx - 10, ly - 8, 8, 8); + ctx.fillStyle = '#5a6380'; + ctx.fillText(m.name.charAt(0).toUpperCase() + m.name.slice(1), lx - 15, ly - 1); + lx -= 60; + }); + + } else { + // Draw Single Line Path + ctx.beginPath(); + ctx.moveTo(getX(0), getY(values[0])); + for (let i = 1; i < len; i++) { + const prevX = getX(i - 1); + const currX = getX(i); + const prevY = getY(values[i - 1]); + const currY = getY(values[i]); + const midX = (prevX + currX) / 2; + ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); + } + + // Stroke + ctx.strokeStyle = '#6366f1'; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Fill + ctx.lineTo(getX(len - 1), p.top + chartH); + ctx.lineTo(getX(0), p.top + chartH); + ctx.closePath(); + const grad = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); + grad.addColorStop(0, 'rgba(99, 102, 241, 0.15)'); + grad.addColorStop(1, 'rgba(99, 102, 241, 0)'); + ctx.fillStyle = grad; + ctx.fill(); + + // Last point pulse + const lastX = getX(len - 1); + const lastY = getY(values[len - 1]); + ctx.beginPath(); + ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); + ctx.fillStyle = '#6366f1'; + ctx.fill(); + } } destroy() { diff --git a/server/prometheus-service.js b/server/prometheus-service.js index 896d6e7..b66383c 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -421,6 +421,14 @@ async function getServerDetails(baseUrl, instance, job) { // Queries based on the requested dashboard structure const queries = { + // Split CPU + cpuSystem: `avg(rate(node_cpu_seconds_total{mode="system", instance="${node}"}[1m])) * 100`, + cpuUser: `avg(rate(node_cpu_seconds_total{mode="user", instance="${node}"}[1m])) * 100`, + cpuIowait: `avg(rate(node_cpu_seconds_total{mode="iowait", instance="${node}"}[1m])) * 100`, + cpuIrq: `avg(rate(node_cpu_seconds_total{mode=~"irq|softirq", instance="${node}"}[1m])) * 100`, + cpuOther: `avg(rate(node_cpu_seconds_total{mode=~"nice|steal|guest|guest_nice", instance="${node}"}[1m])) * 100`, + cpuIdle: `avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])) * 100`, + cpuBusy: `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])))`, sysLoad: `node_load1{instance="${node}",job="${job}"} * 100 / count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`, memUsedPct: `(1 - (node_memory_MemAvailable_bytes{instance="${node}", job="${job}"} / node_memory_MemTotal_bytes{instance="${node}", job="${job}"})) * 100`, @@ -455,9 +463,42 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st const url = normalizeUrl(baseUrl); const node = instance; + // Custom multi-metric handler for CPU Busy + if (metric === 'cpuBusy') { + const modes = { + system: 'system', + user: 'user', + iowait: 'iowait', + irq: 'irq|softirq', + other: 'nice|steal|guest|guest_nice', + idle: 'idle' + }; + + const rangeObj = parseRange(range, start, end); + const timestamps = []; + const series = {}; + Object.keys(modes).forEach(m => series[m] = []); + + const results = await Promise.all(Object.entries(modes).map(async ([name, mode]) => { + const expr = `avg(rate(node_cpu_seconds_total{mode=~"${mode}", instance="${node}"}[1m])) * 100`; + const res = await queryRange(url, expr, rangeObj.queryStart, rangeObj.queryEnd, rangeObj.step); + return { name, values: res.length > 0 ? res[0].values : [] }; + })); + + if (results[0].values.length === 0) return { timestamps: [], series: {} }; + + // Use first result for timestamps + results[0].values.forEach(v => timestamps.push(v[0] * 1000)); + + results.forEach(r => { + r.values.forEach(v => series[r.name].push(parseFloat(v[1]))); + }); + + return { timestamps, series }; + } + // Map metric keys to Prometheus expressions const metricMap = { - cpuBusy: `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])))`, sysLoad: `node_load1{instance="${node}",job="${job}"} * 100 / count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`, memUsedPct: `(1 - (node_memory_MemAvailable_bytes{instance="${node}", job="${job}"} / node_memory_MemTotal_bytes{instance="${node}", job="${job}"})) * 100`, swapUsedPct: `((node_memory_SwapTotal_bytes{instance="${node}",job="${job}"} - node_memory_SwapFree_bytes{instance="${node}",job="${job}"}) / (node_memory_SwapTotal_bytes{instance="${node}",job="${job}"})) * 100`, @@ -469,52 +510,10 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st const expr = metricMap[metric]; if (!expr) throw new Error('Invalid metric for history'); - let duration, step, queryStart, queryEnd; - - if (start && end) { - queryStart = Math.floor(new Date(start).getTime() / 1000); - queryEnd = Math.floor(new Date(end).getTime() / 1000); - duration = queryEnd - queryStart; - if (duration <= 0) throw new Error('End time must be after start time'); - // Reasonable step for fixed range - step = Math.max(15, Math.floor(duration / 100)); - } else { - // Relative range logic - const rangeMap = { - '15m': { duration: 900, step: 15 }, - '30m': { duration: 1800, step: 30 }, - '1h': { duration: 3600, step: 60 }, - '6h': { duration: 21600, step: 300 }, - '12h': { duration: 43200, step: 600 }, - '24h': { duration: 86400, step: 900 }, - '2d': { duration: 172800, step: 1800 }, - '7d': { duration: 604800, step: 3600 } - }; - - if (rangeMap[range]) { - duration = rangeMap[range].duration; - step = rangeMap[range].step; - } else { - // Try to parse relative time string like "2h", "30m", "1d" - const match = range.match(/^(\d+)([smhd])$/); - if (match) { - const val = parseInt(match[1]); - const unit = match[2]; - const multipliers = { s: 1, m: 60, h: 3600, d: 86400 }; - duration = val * (multipliers[unit] || 3600); - // Calculate a reasonable step for ~60-120 data points - step = Math.max(15, Math.floor(duration / 100)); - } else { - duration = 3600; - step = 60; - } - } - queryEnd = Math.floor(Date.now() / 1000); - queryStart = queryEnd - duration; - } + const rangeObj = parseRange(range, start, end); try { - const result = await queryRange(url, expr, queryStart, queryEnd, step); + const result = await queryRange(url, expr, rangeObj.queryStart, rangeObj.queryEnd, rangeObj.step); if (!result || result.length === 0) return { timestamps: [], values: [] }; return { @@ -527,6 +526,48 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st } } +function parseRange(range, start, end) { + let duration, step, queryStart, queryEnd; + + if (start && end) { + queryStart = Math.floor(new Date(start).getTime() / 1000); + queryEnd = Math.floor(new Date(end).getTime() / 1000); + duration = queryEnd - queryStart; + step = Math.max(15, Math.floor(duration / 100)); + } else { + const rangeMap = { + '15m': { duration: 900, step: 15 }, + '30m': { duration: 1800, step: 30 }, + '1h': { duration: 3600, step: 60 }, + '6h': { duration: 21600, step: 300 }, + '12h': { duration: 43200, step: 600 }, + '24h': { duration: 86400, step: 900 }, + '2d': { duration: 172800, step: 1800 }, + '7d': { duration: 604800, step: 3600 } + }; + + if (rangeMap[range]) { + duration = rangeMap[range].duration; + step = rangeMap[range].step; + } else { + const match = range.match(/^(\d+)([smhd])$/); + if (match) { + const val = parseInt(match[1]); + const unit = match[2]; + const multipliers = { s: 1, m: 60, h: 3600, d: 86400 }; + duration = val * (multipliers[unit] || 3600); + step = Math.max(15, Math.floor(duration / 100)); + } else { + duration = 3600; + step = 60; + } + } + queryEnd = Math.floor(Date.now() / 1000); + queryStart = queryEnd - duration; + } + return { queryStart, queryEnd, step }; +} + module.exports = { testConnection, query,