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,