-
- CPU 忙碌率 (Busy)
- 0%
-
-
- 系统负载 (Load)
- 0%
-
-
- 内存使用率 (RAM)
- 0%
-
-
- SWAP 使用率
- 0%
-
-
- 根分区使用率 (/)
- 0%
-
-
- CPU 核心总数
- 0 核心
-
-
- 物理内存总量
- 0 GB
-
-
- 运行时间 (Uptime)
- 0天 0小时
-
-
- 网络接收速率 (RX)
- 0 Mbps
-
-
-
网络发送速率 (TX)
-
0 Mbps
+
+
+
+
+
+
+ CPU 核心总数
+ 0 核心
+
+
+ 物理内存总量
+ 0 GB
+
+
+ 运行时间 (Uptime)
+ 0天 0小时
+
diff --git a/public/js/app.js b/public/js/app.js
index 9d36687..a2aebd6 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -65,24 +65,20 @@
serverDetailModal: document.getElementById('serverDetailModal'),
serverDetailClose: document.getElementById('serverDetailClose'),
serverDetailTitle: document.getElementById('serverDetailTitle'),
- detailGrid: document.getElementById('detailGrid'),
+ serverDetailSubtitle: document.getElementById('serverDetailSubtitle'),
+ detailMetricsList: document.getElementById('detailMetricsList'),
detailLoading: document.getElementById('detailLoading'),
- detailCpuBusy: document.getElementById('detailCpuBusy'),
- detailSysLoad: document.getElementById('detailSysLoad'),
- detailMemUsedPct: document.getElementById('detailMemUsedPct'),
- detailSwapUsedPct: document.getElementById('detailSwapUsedPct'),
- detailRootFsUsedPct: document.getElementById('detailRootFsUsedPct'),
detailCpuCores: document.getElementById('detailCpuCores'),
detailMemTotal: document.getElementById('detailMemTotal'),
detailUptime: document.getElementById('detailUptime'),
- detailNetRx: document.getElementById('detailNetRx'),
- detailNetTx: document.getElementById('detailNetTx')
+ detailContainer: document.getElementById('detailContainer')
};
// ---- State ----
let previousMetrics = null;
let networkChart = null;
let user = null; // Currently logged in user
+ let currentServerDetail = { instance: null, job: null, source: null, charts: {} };
// ---- Initialize ----
function init() {
@@ -431,52 +427,185 @@
// ---- Server Detail ----
async function openServerDetail(instance, job, source) {
- dom.serverDetailTitle.textContent = `${job} - 服务器详情`;
+ currentServerDetail = { instance, job, source, charts: {} };
+ dom.serverDetailTitle.textContent = `${job}`;
+ dom.serverDetailSubtitle.textContent = `${instance} (${source})`;
dom.serverDetailModal.classList.add('active');
-
+
// Show loading
- dom.detailGrid.style.opacity = '0.3';
+ dom.detailContainer.style.opacity = '0.3';
dom.detailLoading.style.display = 'block';
+ dom.detailMetricsList.innerHTML = '';
try {
const url = `/api/metrics/server-details?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
const data = await response.json();
-
+
renderServerDetail(data);
} catch (err) {
console.error('Error fetching server details:', err);
} finally {
- dom.detailGrid.style.opacity = '1';
+ dom.detailContainer.style.opacity = '1';
dom.detailLoading.style.display = 'none';
}
}
function closeServerDetail() {
dom.serverDetailModal.classList.remove('active');
+ // Destroy charts
+ Object.values(currentServerDetail.charts).forEach(chart => {
+ if (chart && chart.destroy) chart.destroy();
+ });
+ currentServerDetail = { instance: null, job: null, source: null, charts: {} };
}
function renderServerDetail(data) {
- dom.detailCpuBusy.textContent = formatPercent(data.cpuBusy);
- dom.detailSysLoad.textContent = data.sysLoad.toFixed(1) + '%';
- dom.detailMemUsedPct.textContent = formatPercent(data.memUsedPct);
- dom.detailSwapUsedPct.textContent = formatPercent(data.swapUsedPct);
- dom.detailRootFsUsedPct.textContent = formatPercent(data.rootFsUsedPct);
dom.detailCpuCores.textContent = data.cpuCores + ' 核心';
dom.detailMemTotal.textContent = formatBytes(data.memTotal);
-
+
// Uptime formatting
const uptimeSec = data.uptime;
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
dom.detailUptime.textContent = `${days}天 ${hours}小时 ${mins}分`;
-
- dom.detailNetRx.textContent = formatBandwidth(data.netRx);
- dom.detailNetTx.textContent = formatBandwidth(data.netTx);
+
+ // Define metrics to show
+ 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) }
+ ];
+
+ dom.detailMetricsList.innerHTML = metrics.map(m => `
+
+ `).join('');
}
+ window.toggleMetricExpand = async function (metricKey) {
+ const el = document.getElementById(`metric-${metricKey}`);
+ const wasActive = el.classList.contains('active');
+
+ // Close all others
+ document.querySelectorAll('.metric-item').forEach(item => item.classList.remove('active'));
+
+ if (!wasActive) {
+ el.classList.add('active');
+ // Initial load
+ loadMetricHistory(metricKey, '1h');
+ }
+ };
+
+ window.loadMetricHistory = async function (metricKey, range, event, start = null, end = null) {
+ if (event) {
+ event.stopPropagation();
+ const group = event.target.closest('.time-range-group');
+ if (group) {
+ group.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
+ if (event.target.classList.contains('time-range-btn')) {
+ event.target.classList.add('active');
+ }
+ }
+ }
+
+ const canvas = document.getElementById(`chart-${metricKey}`);
+ if (!canvas) return;
+
+ let chart = currentServerDetail.charts[metricKey];
+ if (!chart) {
+ let unit = '';
+ if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
+ if (metricKey.startsWith('net')) unit = 'B/s';
+
+ chart = new MetricChart(canvas, unit);
+ currentServerDetail.charts[metricKey] = chart;
+ }
+
+ try {
+ const { instance, job, source } = currentServerDetail;
+ let url = `/api/metrics/server-history?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}&metric=${metricKey}`;
+
+ if (start && end) {
+ url += `&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
+ } else {
+ url += `&range=${range}`;
+ }
+
+ const res = await fetch(url);
+ if (!res.ok) throw new Error('Query failed');
+ const data = await res.json();
+ chart.setData(data);
+ } catch (err) {
+ console.error(`Error loading history for ${metricKey}:`, err);
+ }
+ };
+
+ window.loadCustomMetricHistory = async function (metricKey, event) {
+ if (event) event.stopPropagation();
+
+ const rangeInput = document.getElementById(`custom-range-${metricKey}`);
+ const startInput = document.getElementById(`start-time-${metricKey}`);
+ const endInput = document.getElementById(`end-time-${metricKey}`);
+
+ const range = (rangeInput.value || '').trim().toLowerCase();
+ const startTime = startInput.value;
+ const endTime = endInput.value;
+
+ if (startTime && endTime) {
+ // Absolute range
+ loadMetricHistory(metricKey, null, event, startTime, endTime);
+ } else if (range) {
+ // Relative range
+ if (!/^\d+[smhd]$/.test(range)) {
+ alert('格式不正确,请使用如: 2h, 30m, 1d 等格式');
+ return;
+ }
+ loadMetricHistory(metricKey, range, event);
+ } else {
+ alert('请输入相对范围或选择具体时间范围');
+ }
+ };
+
// ---- Network History ----
async function fetchNetworkHistory() {
try {
diff --git a/public/js/chart.js b/public/js/chart.js
index 7ee2262..53b2a88 100644
--- a/public/js/chart.js
+++ b/public/js/chart.js
@@ -273,3 +273,154 @@ class AreaChart {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
}
}
+
+class MetricChart {
+ constructor(canvas, unit = '') {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.data = { timestamps: [], values: [] };
+ this.unit = unit; // '%', 'B/s', etc.
+ this.dpr = window.devicePixelRatio || 1;
+ this.padding = { top: 10, right: 10, bottom: 20, left: 60 };
+ this.animProgress = 0;
+
+ this._resize = this.resize.bind(this);
+ window.addEventListener('resize', this._resize);
+ this.resize();
+ }
+
+ resize() {
+ const parent = this.canvas.parentElement;
+ if (!parent) return;
+ const rect = parent.getBoundingClientRect();
+ if (rect.width === 0) return;
+ this.width = rect.width;
+ this.height = rect.height;
+ this.canvas.width = this.width * this.dpr;
+ this.canvas.height = this.height * this.dpr;
+ this.canvas.style.width = this.width + 'px';
+ this.canvas.style.height = this.height + 'px';
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ this.draw();
+ }
+
+ setData(data) {
+ this.data = data || { timestamps: [], values: [] };
+ this.animate();
+ }
+
+ animate() {
+ if (this.animFrame) cancelAnimationFrame(this.animFrame);
+ const start = performance.now();
+ const duration = 500;
+ const step = (now) => {
+ const elapsed = now - start;
+ this.animProgress = Math.min(elapsed / duration, 1);
+ this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
+ this.draw();
+ if (elapsed < duration) this.animFrame = requestAnimationFrame(step);
+ };
+ this.animFrame = requestAnimationFrame(step);
+ }
+
+ draw() {
+ const ctx = this.ctx;
+ const w = this.width;
+ const h = this.height;
+ const p = this.padding;
+ const chartW = w - p.left - p.right;
+ const chartH = h - p.top - p.bottom;
+
+ ctx.clearRect(0, 0, w, h);
+
+ const { timestamps, values } = this.data;
+ if (!timestamps || timestamps.length < 2) {
+ ctx.fillStyle = '#5a6380';
+ ctx.font = '11px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('正在加载或暂无数据...', w / 2, h / 2);
+ 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;
+ } else {
+ maxVal = maxVal * 1.25;
+ }
+
+ const len = timestamps.length;
+ const xStep = chartW / (len - 1);
+ const getX = (i) => p.left + i * xStep;
+ const getY = (val) => p.top + chartH - (val / (maxVal || 1)) * chartH * this.animProgress;
+
+ // Grid
+ ctx.strokeStyle = 'rgba(99, 102, 241, 0.05)';
+ ctx.lineWidth = 1;
+ for (let i = 0; i <= 3; i++) {
+ const y = p.top + (chartH / 3) * i;
+ ctx.beginPath();
+ ctx.moveTo(p.left, y);
+ ctx.lineTo(p.left + chartW, y);
+ ctx.stroke();
+
+ const v = (maxVal * (1 - i / 3));
+ ctx.fillStyle = '#5a6380';
+ ctx.font = '9px "JetBrains Mono", monospace';
+ ctx.textAlign = 'right';
+
+ let label = '';
+ if (this.unit === 'B/s') {
+ label = window.formatBandwidth ? window.formatBandwidth(v) : v.toFixed(0);
+ } else {
+ label = (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(v < 10 && v > 0 ? 1 : 0)) + this.unit;
+ }
+ 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();
+
+ // 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() {
+ window.removeEventListener('resize', this._resize);
+ if (this.animFrame) cancelAnimationFrame(this.animFrame);
+ }
+}
diff --git a/server/index.js b/server/index.js
index ecdbfed..a76d918 100644
--- a/server/index.js
+++ b/server/index.js
@@ -626,6 +626,26 @@ app.get('/api/metrics/server-details', async (req, res) => {
}
});
+// Get historical metrics for a specific server
+app.get('/api/metrics/server-history', async (req, res) => {
+ const { instance, job, source, metric, range, start, end } = req.query;
+
+ if (!instance || !job || !source || !metric) {
+ return res.status(400).json({ error: 'instance, job, source, and metric are required' });
+ }
+
+ try {
+ const [rows] = await db.query('SELECT url FROM prometheus_sources WHERE name = ?', [source]);
+ if (rows.length === 0) return res.status(404).json({ error: 'Source not found' });
+ const sourceUrl = rows[0].url;
+
+ const data = await prometheusService.getServerHistory(sourceUrl, instance, job, metric, range, start, end);
+ res.json(data);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
// SPA fallback
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.includes('.')) return next();
diff --git a/server/prometheus-service.js b/server/prometheus-service.js
index 24a1f76..896d6e7 100644
--- a/server/prometheus-service.js
+++ b/server/prometheus-service.js
@@ -448,6 +448,85 @@ async function getServerDetails(baseUrl, instance, job) {
return results;
}
+/**
+ * Get historical metrics for a specific server (node)
+ */
+async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null) {
+ const url = normalizeUrl(baseUrl);
+ const node = instance;
+
+ // 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`,
+ rootFsUsedPct: `100 - ((node_filesystem_avail_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!="rootfs"} * 100) / node_filesystem_size_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!="rootfs"})`,
+ netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
+ netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`
+ };
+
+ 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;
+ }
+
+ try {
+ const result = await queryRange(url, expr, queryStart, queryEnd, step);
+ if (!result || result.length === 0) return { timestamps: [], values: [] };
+
+ return {
+ timestamps: result[0].values.map(v => v[0] * 1000),
+ values: result[0].values.map(v => parseFloat(v[1]))
+ };
+ } catch (err) {
+ console.error(`[Prometheus] Error fetching history for ${metric} on ${node}:`, err.message);
+ throw err;
+ }
+}
+
module.exports = {
testConnection,
query,
@@ -457,5 +536,6 @@ module.exports = {
mergeNetworkHistories,
getCpuHistory,
mergeCpuHistories,
- getServerDetails
+ getServerDetails,
+ getServerHistory
};