From e8b60ce28b9cdb157d324c96c50ca98243bce7e7 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Mon, 6 Apr 2026 00:24:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=BA=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E4=B8=8Eblackbox=E9=80=9A=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/style.css | 29 ++++++++------ public/index.html | 17 +++++--- public/js/app.js | 26 ++++++++---- server/db-integrity-check.js | 9 ++++- server/index.js | 45 +++++++++++++++------ server/latency-service.js | 77 ++++++++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 server/latency-service.js diff --git a/public/css/style.css b/public/css/style.css index f9fde44..079f891 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1987,18 +1987,24 @@ input:checked+.slider:before { /* ---- Globe Card Expansion ---- */ .globe-card.expanded { position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + top: 5vh; + left: 2.5vw; width: 95vw !important; height: 90vh !important; - z-index: 2000; - box-shadow: 0 0 100px rgba(0, 0, 0, 0.8), 0 0 0 100vh rgba(0, 0, 0, 0.6); - backdrop-filter: blur(15px); - animation: globeExpand 0.4s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 9999; + transform: none !important; /* Remove translate to avoid coordinate issues */ + transition: none !important; /* Avoid transition conflicts during state jump */ + box-shadow: 0 0 100px rgba(0, 0, 0, 0.9), 0 0 0 100vh rgba(0, 0, 0, 0.7); + backdrop-filter: blur(20px); margin: 0 !important; - display: flex !important; /* Force flex for proper internal layout */ + display: flex !important; flex-direction: column; + border-color: var(--accent-indigo); +} + +/* Ensure children are visible */ +.globe-card.expanded > * { + opacity: 1 !important; } @keyframes globeExpand { @@ -2013,9 +2019,10 @@ input:checked+.slider:before { } .globe-card.expanded .globe-body { - flex: 1; /* Allow map to fill all remaining space */ - height: auto !important; /* Override fixed height in expanded state */ - min-height: 0; + height: calc(90vh - 120px) !important; /* Explicit calc height for ECharts reliability */ + width: 100% !important; + flex: none; + min-height: 400px; } .chart-header-actions { diff --git a/public/index.html b/public/index.html index 4f438ca..66c9f4b 100644 --- a/public/index.html +++ b/public/index.html @@ -369,13 +369,20 @@

添加数据源

-
- - +
+ + +
+
+ +
- - + +
diff --git a/public/js/app.js b/public/js/app.js index a3f3421..f92659a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -33,6 +33,7 @@ modalClose: document.getElementById('modalClose'), sourceName: document.getElementById('sourceName'), sourceUrl: document.getElementById('sourceUrl'), + sourceType: document.getElementById('sourceType'), sourceDesc: document.getElementById('sourceDesc'), btnTest: document.getElementById('btnTest'), btnAdd: document.getElementById('btnAdd'), @@ -185,11 +186,17 @@ dom.globeCard.classList.toggle('expanded'); dom.btnExpandGlobe.classList.toggle('active'); if (myMap2D) { - // Multiple resizes to handle animation phases and final layout - myMap2D.resize(); - setTimeout(() => myMap2D.resize(), 100); - setTimeout(() => myMap2D.resize(), 300); - setTimeout(() => myMap2D.resize(), 600); // Final resize after animation + // Immediately hide and then show map or just resize? + // ECharts can sometimes glitch when position:fixed + transform happens. + // Since we removed transform, resize should be smoother. + myMap2D.resize(); + + let resizeCount = 0; + const timer = setInterval(() => { + myMap2D.resize(); + resizeCount++; + if (resizeCount >= 5) clearInterval(timer); + }, 100); } }); } @@ -1716,7 +1723,7 @@ ${source.status === 'online' ? '在线' : '离线'} - ${source.is_server_source ? '服务器看板' : '独立数据源'} + ${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
${escapeHtml(source.url)}
@@ -1737,6 +1744,8 @@ return; } + const type = dom.sourceType.value; + dom.btnTest.textContent = '测试中...'; dom.btnTest.disabled = true; @@ -1744,7 +1753,7 @@ const response = await fetch('/api/sources/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }) + body: JSON.stringify({ url, type }) }); const data = await response.json(); if (data.status === 'ok') { @@ -1775,6 +1784,7 @@ const name = dom.sourceName.value.trim(); const url = dom.sourceUrl.value.trim(); + const type = dom.sourceType.value; const description = dom.sourceDesc.value.trim(); const is_server_source = dom.isServerSource.checked; @@ -1790,7 +1800,7 @@ const response = await fetch('/api/sources', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, url, description, is_server_source }) + body: JSON.stringify({ name, url, description, is_server_source, type }) }); if (response.ok) { diff --git a/server/db-integrity-check.js b/server/db-integrity-check.js index dbb3f8f..ae2d139 100644 --- a/server/db-integrity-check.js +++ b/server/db-integrity-check.js @@ -38,15 +38,22 @@ async function checkAndFixDatabase() { console.log(`[Database Integrity] ✅ Missing tables created.`); } - // Check for is_server_source in prometheus_sources + // Check for is_server_source and type in prometheus_sources const [promColumns] = await db.query("SHOW COLUMNS FROM prometheus_sources"); const promColumnNames = promColumns.map(c => c.Field); + if (!promColumnNames.includes('is_server_source')) { console.log(`[Database Integrity] ⚠️ Missing column 'is_server_source' in 'prometheus_sources'. Adding it...`); await db.query("ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description"); console.log(`[Database Integrity] ✅ Column 'is_server_source' added.`); } + if (!promColumnNames.includes('type')) { + console.log(`[Database Integrity] ⚠️ Missing column 'type' in 'prometheus_sources'. Adding it...`); + await db.query("ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source"); + console.log(`[Database Integrity] ✅ Column 'type' added.`); + } + // Check for new columns in site_settings const [columns] = await db.query("SHOW COLUMNS FROM site_settings"); const columnNames = columns.map(c => c.Field); diff --git a/server/index.js b/server/index.js index aa7355f..c1db118 100644 --- a/server/index.js +++ b/server/index.js @@ -6,6 +6,7 @@ const db = require('./db'); const prometheusService = require('./prometheus-service'); const cache = require('./cache'); const geoService = require('./geo-service'); +const latencyService = require('./latency-service'); const checkAndFixDatabase = require('./db-integrity-check'); const http = require('http'); const WebSocket = require('ws'); @@ -451,7 +452,14 @@ app.get('/api/sources', async (req, res) => { // Test connectivity for each source const sourcesWithStatus = await Promise.all(rows.map(async (source) => { try { - const response = await prometheusService.testConnection(source.url); + let response; + if (source.type === 'blackbox') { + // Simple check for blackbox exporter + const res = await fetch(`${source.url.replace(/\/+$/, '')}/metrics`, { timeout: 3000 }).catch(() => null); + response = (res && res.ok) ? 'Blackbox Exporter Ready' : 'Connection Error'; + } else { + response = await prometheusService.testConnection(source.url); + } return { ...source, status: 'online', version: response }; } catch (e) { return { ...source, status: 'offline', version: null }; @@ -466,15 +474,15 @@ app.get('/api/sources', async (req, res) => { // Add a new Prometheus source app.post('/api/sources', requireAuth, async (req, res) => { - let { name, url, description, is_server_source } = req.body; + let { name, url, description, is_server_source, type } = req.body; if (!name || !url) { return res.status(400).json({ error: 'Name and URL are required' }); } if (!/^https?:\/\//i.test(url)) url = 'http://' + url; try { const [result] = await db.query( - 'INSERT INTO prometheus_sources (name, url, description, is_server_source) VALUES (?, ?, ?, ?)', - [name, url, description || '', is_server_source === undefined ? 1 : (is_server_source ? 1 : 0)] + 'INSERT INTO prometheus_sources (name, url, description, is_server_source, type) VALUES (?, ?, ?, ?, ?)', + [name, url, description || '', is_server_source === undefined ? 1 : (is_server_source ? 1 : 0), type || 'prometheus'] ); const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]); @@ -494,8 +502,8 @@ app.put('/api/sources/:id', requireAuth, async (req, res) => { if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url; try { await db.query( - 'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ? WHERE id = ?', - [name, url, description || '', is_server_source ? 1 : 0, req.params.id] + 'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ?, type = ? WHERE id = ?', + [name, url, description || '', is_server_source ? 1 : 0, type || 'prometheus', req.params.id] ); // Clear network history cache await cache.del('network_history_all'); @@ -523,11 +531,18 @@ app.delete('/api/sources/:id', requireAuth, async (req, res) => { // Test connection to a Prometheus source app.post('/api/sources/test', async (req, res) => { - let { url } = req.body; + let { url, type } = req.body; if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url; try { - const version = await prometheusService.testConnection(url); - res.json({ status: 'ok', version }); + let result; + if (type === 'blackbox') { + const resVal = await fetch(`${url.replace(/\/+$/, '')}/metrics`, { timeout: 5000 }).catch(() => null); + result = (resVal && resVal.ok) ? 'Blackbox Exporter Ready' : 'Connection Failed'; + if (!resVal || !resVal.ok) throw new Error(result); + } else { + result = await prometheusService.testConnection(url); + } + res.json({ status: 'ok', version: result }); } catch (err) { res.status(400).json({ status: 'error', message: err.message }); } @@ -893,7 +908,14 @@ app.get('/api/metrics/latency', async (req, res) => { } const results = await Promise.all(routes.map(async (route) => { - const latency = await prometheusService.getLatency(route.url, route.latency_target); + // Try to get from Valkey first (filled by background latencyService) + let latency = await cache.get(`latency:route:${route.id}`); + + // Fallback if not in cache (maybe service just started or failed) + if (latency === null) { + latency = await prometheusService.getLatency(route.url, route.latency_target); + } + return { id: route.id, source: route.latency_source, @@ -933,8 +955,9 @@ async function broadcastMetrics() { } } -// Check and fix database integrity on startup +// Start services checkAndFixDatabase(); +latencyService.start(); const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000; setInterval(broadcastMetrics, REFRESH_INT); diff --git a/server/latency-service.js b/server/latency-service.js new file mode 100644 index 0000000..4e544c6 --- /dev/null +++ b/server/latency-service.js @@ -0,0 +1,77 @@ +const axios = require('axios'); +const cache = require('./cache'); +const db = require('./db'); + +const POLL_INTERVAL = 10000; // 10 seconds + +async function pollLatency() { + try { + const [routes] = await db.query(` + SELECT r.*, s.url + FROM latency_routes r + JOIN prometheus_sources s ON r.source_id = s.id + WHERE s.type = 'blackbox' + `); + + if (routes.length === 0) return; + + // Poll each route + await Promise.allSettled(routes.map(async (route) => { + try { + // Blackbox exporter probe URL + // We assume ICMP module for now. If target is a URL, maybe use http_2xx + let module = 'icmp'; + let target = route.latency_target; + + if (target.startsWith('http://') || target.startsWith('https://')) { + module = 'http_2xx'; + } + + const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`; + + const startTime = Date.now(); + const response = await axios.get(probeUrl, { timeout: 5000 }); + const duration = (Date.now() - startTime) / 1000; // Fallback to local timing if parsing fails + + // Parse prometheus text format for probe_duration_seconds + let latency = null; + const lines = response.data.split('\n'); + for (const line of lines) { + // Match "probe_duration_seconds 0.123" or "probe_duration_seconds{...} 0.123" + const match = line.match(/^probe_duration_seconds(?:\{.*\})?\s+([\d.]+)/); + if (match) { + latency = parseFloat(match[1]) * 1000; // to ms + break; + } + } + + if (latency === null) { + // Fallback to local response time if metric not found in output + latency = duration * 1000; + } + + // Save to Valkey + await cache.set(`latency:route:${route.id}`, latency, 60); + // console.log(`[Latency] Route ${route.id} (${target}): ${latency.toFixed(2)}ms`); + } catch (err) { + // console.error(`[Latency] Error polling route ${route.id}:`, err.message); + await cache.set(`latency:route:${route.id}`, null, 60); + } + })); + } catch (err) { + console.error('[Latency] Service error:', err.message); + } +} + +let intervalId = null; + +function start() { + if (intervalId) clearInterval(intervalId); + pollLatency(); // initial run + intervalId = setInterval(pollLatency, POLL_INTERVAL); + console.log('[Latency] Background service started (polling Blackbox Exporter directly)'); +} + +module.exports = { + start +};