diff --git a/public/css/style.css b/public/css/style.css index e25ff1f..4dbaff6 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1254,6 +1254,43 @@ input:checked+.slider:before { align-items: center; } +/* ---- Server Detail Grid ---- */ +.detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-top: 10px; +} + +.detail-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + padding: 16px; + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detail-value { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 700; + color: var(--accent-indigo); +} + +:root.light-theme .detail-value { + color: var(--accent-blue); +} + /* ---- Animations ---- */ @keyframes fadeInUp { from { diff --git a/public/index.html b/public/index.html index bd7bd0a..8132fba 100644 --- a/public/index.html +++ b/public/index.html @@ -364,6 +364,64 @@ + + + diff --git a/public/js/app.js b/public/js/app.js index 4576c51..9d36687 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -60,7 +60,23 @@ loginForm: document.getElementById('loginForm'), loginError: document.getElementById('loginError'), footerTime: document.getElementById('footerTime'), - legendP95: document.getElementById('legendP95') + legendP95: document.getElementById('legendP95'), + // Server Details Modal + serverDetailModal: document.getElementById('serverDetailModal'), + serverDetailClose: document.getElementById('serverDetailClose'), + serverDetailTitle: document.getElementById('serverDetailTitle'), + detailGrid: document.getElementById('detailGrid'), + 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') }; // ---- State ---- @@ -113,6 +129,26 @@ if (e.key === 'Escape') { closeSettings(); closeLoginModal(); + closeServerDetail(); + } + }); + + // Server detail modal listeners + dom.serverDetailClose.addEventListener('click', closeServerDetail); + dom.serverDetailModal.addEventListener('click', (e) => { + if (e.target === dom.serverDetailModal) closeServerDetail(); + }); + + // Server table row click delegator + dom.serverTableBody.addEventListener('click', (e) => { + const row = e.target.closest('tr'); + if (row && !row.classList.contains('empty-row')) { + const instance = row.getAttribute('data-instance'); + const job = row.getAttribute('data-job'); + const source = row.getAttribute('data-source'); + if (instance && job && source) { + openServerDetail(instance, job, source); + } } }); @@ -354,7 +390,7 @@ const diskPct = server.diskTotal > 0 ? (server.diskUsed / server.diskTotal * 100) : 0; return ` - + @@ -393,6 +429,54 @@ }).join(''); } + // ---- Server Detail ---- + async function openServerDetail(instance, job, source) { + dom.serverDetailTitle.textContent = `${job} - 服务器详情`; + dom.serverDetailModal.classList.add('active'); + + // Show loading + dom.detailGrid.style.opacity = '0.3'; + dom.detailLoading.style.display = 'block'; + + 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.detailLoading.style.display = 'none'; + } + } + + function closeServerDetail() { + dom.serverDetailModal.classList.remove('active'); + } + + 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); + } + // ---- Network History ---- async function fetchNetworkHistory() { try { diff --git a/server/index.js b/server/index.js index cf5d673..ecdbfed 100644 --- a/server/index.js +++ b/server/index.js @@ -601,6 +601,31 @@ app.get('/api/metrics/cpu-history', async (req, res) => { } }); +// Get detailed metrics for a specific server +app.get('/api/metrics/server-details', async (req, res) => { + const { instance, job, source } = req.query; + + if (!instance || !job || !source) { + return res.status(400).json({ error: 'instance, job, and source name are required' }); + } + + try { + // Find the source URL by name + const [rows] = await db.query('SELECT url FROM prometheus_sources WHERE name = ?', [source]); + if (rows.length === 0) { + return res.status(404).json({ error: 'Prometheus source not found' }); + } + const sourceUrl = rows[0].url; + + // Fetch detailed metrics + const details = await prometheusService.getServerDetails(sourceUrl, instance, job); + res.json(details); + } catch (err) { + console.error(`Error fetching server details for ${instance}:`, err.message); + res.status(500).json({ error: 'Failed to fetch server details' }); + } +}); + // 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 f1c490d..24a1f76 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -412,6 +412,42 @@ function mergeCpuHistories(histories) { } +/** + * Get detailed metrics for a specific server (node) + */ +async function getServerDetails(baseUrl, instance, job) { + const url = normalizeUrl(baseUrl); + const node = instance; + + // Queries based on the requested dashboard structure + const queries = { + 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"})`, + cpuCores: `count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`, + memTotal: `node_memory_MemTotal_bytes{instance="${node}",job="${job}"}`, + uptime: `node_time_seconds{instance="${node}",job="${job}"} - node_boot_time_seconds{instance="${node}",job="${job}"}`, + 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 results = {}; + const queryPromises = Object.entries(queries).map(async ([key, expr]) => { + try { + const res = await query(url, expr); + results[key] = res.length > 0 ? parseFloat(res[0].value[1]) : 0; + } catch (e) { + console.error(`[Prometheus] Error querying ${key} for ${node}:`, e.message); + results[key] = 0; + } + }); + + await Promise.all(queryPromises); + return results; +} + module.exports = { testConnection, query, @@ -420,5 +456,6 @@ module.exports = { getNetworkHistory, mergeNetworkHistories, getCpuHistory, - mergeCpuHistories + mergeCpuHistories, + getServerDetails };