修改为直接与blackbox通信
This commit is contained in:
@@ -1987,18 +1987,24 @@ input:checked+.slider:before {
|
|||||||
/* ---- Globe Card Expansion ---- */
|
/* ---- Globe Card Expansion ---- */
|
||||||
.globe-card.expanded {
|
.globe-card.expanded {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 5vh;
|
||||||
left: 50%;
|
left: 2.5vw;
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 95vw !important;
|
width: 95vw !important;
|
||||||
height: 90vh !important;
|
height: 90vh !important;
|
||||||
z-index: 2000;
|
z-index: 9999;
|
||||||
box-shadow: 0 0 100px rgba(0, 0, 0, 0.8), 0 0 0 100vh rgba(0, 0, 0, 0.6);
|
transform: none !important; /* Remove translate to avoid coordinate issues */
|
||||||
backdrop-filter: blur(15px);
|
transition: none !important; /* Avoid transition conflicts during state jump */
|
||||||
animation: globeExpand 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
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;
|
margin: 0 !important;
|
||||||
display: flex !important; /* Force flex for proper internal layout */
|
display: flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
border-color: var(--accent-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure children are visible */
|
||||||
|
.globe-card.expanded > * {
|
||||||
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes globeExpand {
|
@keyframes globeExpand {
|
||||||
@@ -2013,9 +2019,10 @@ input:checked+.slider:before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.globe-card.expanded .globe-body {
|
.globe-card.expanded .globe-body {
|
||||||
flex: 1; /* Allow map to fill all remaining space */
|
height: calc(90vh - 120px) !important; /* Explicit calc height for ECharts reliability */
|
||||||
height: auto !important; /* Override fixed height in expanded state */
|
width: 100% !important;
|
||||||
min-height: 0;
|
flex: none;
|
||||||
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-header-actions {
|
.chart-header-actions {
|
||||||
|
|||||||
@@ -369,13 +369,20 @@
|
|||||||
<div class="add-source-form" id="addSourceForm">
|
<div class="add-source-form" id="addSourceForm">
|
||||||
<h3>添加数据源</h3>
|
<h3>添加数据源</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group" style="flex: 0.8;">
|
||||||
|
<label for="sourceType">类型</label>
|
||||||
|
<select id="sourceType" style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); outline: none;">
|
||||||
|
<option value="prometheus">Prometheus</option>
|
||||||
|
<option value="blackbox">Blackbox Exporter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 1;">
|
||||||
<label for="sourceName">名称</label>
|
<label for="sourceName">名称</label>
|
||||||
<input type="text" id="sourceName" placeholder="例:生产环境" autocomplete="off">
|
<input type="text" id="sourceName" placeholder="例:生产环境" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group form-group-wide">
|
<div class="form-group form-group-wide">
|
||||||
<label for="sourceUrl">Prometheus URL</label>
|
<label for="sourceUrl">URL 地址</label>
|
||||||
<input type="url" id="sourceUrl" placeholder="http://prometheus.example.com:9090" autocomplete="off">
|
<input type="url" id="sourceUrl" placeholder="http://1.2.3.4:9090" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
modalClose: document.getElementById('modalClose'),
|
modalClose: document.getElementById('modalClose'),
|
||||||
sourceName: document.getElementById('sourceName'),
|
sourceName: document.getElementById('sourceName'),
|
||||||
sourceUrl: document.getElementById('sourceUrl'),
|
sourceUrl: document.getElementById('sourceUrl'),
|
||||||
|
sourceType: document.getElementById('sourceType'),
|
||||||
sourceDesc: document.getElementById('sourceDesc'),
|
sourceDesc: document.getElementById('sourceDesc'),
|
||||||
btnTest: document.getElementById('btnTest'),
|
btnTest: document.getElementById('btnTest'),
|
||||||
btnAdd: document.getElementById('btnAdd'),
|
btnAdd: document.getElementById('btnAdd'),
|
||||||
@@ -185,11 +186,17 @@
|
|||||||
dom.globeCard.classList.toggle('expanded');
|
dom.globeCard.classList.toggle('expanded');
|
||||||
dom.btnExpandGlobe.classList.toggle('active');
|
dom.btnExpandGlobe.classList.toggle('active');
|
||||||
if (myMap2D) {
|
if (myMap2D) {
|
||||||
// Multiple resizes to handle animation phases and final layout
|
// 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();
|
myMap2D.resize();
|
||||||
setTimeout(() => myMap2D.resize(), 100);
|
|
||||||
setTimeout(() => myMap2D.resize(), 300);
|
let resizeCount = 0;
|
||||||
setTimeout(() => myMap2D.resize(), 600); // Final resize after animation
|
const timer = setInterval(() => {
|
||||||
|
myMap2D.resize();
|
||||||
|
resizeCount++;
|
||||||
|
if (resizeCount >= 5) clearInterval(timer);
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1716,7 +1723,7 @@
|
|||||||
${source.status === 'online' ? '在线' : '离线'}
|
${source.status === 'online' ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
|
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
|
||||||
${source.is_server_source ? '服务器看板' : '独立数据源'}
|
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="source-item-url">${escapeHtml(source.url)}</div>
|
<div class="source-item-url">${escapeHtml(source.url)}</div>
|
||||||
@@ -1737,6 +1744,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const type = dom.sourceType.value;
|
||||||
|
|
||||||
dom.btnTest.textContent = '测试中...';
|
dom.btnTest.textContent = '测试中...';
|
||||||
dom.btnTest.disabled = true;
|
dom.btnTest.disabled = true;
|
||||||
|
|
||||||
@@ -1744,7 +1753,7 @@
|
|||||||
const response = await fetch('/api/sources/test', {
|
const response = await fetch('/api/sources/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url })
|
body: JSON.stringify({ url, type })
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
@@ -1775,6 +1784,7 @@
|
|||||||
|
|
||||||
const name = dom.sourceName.value.trim();
|
const name = dom.sourceName.value.trim();
|
||||||
const url = dom.sourceUrl.value.trim();
|
const url = dom.sourceUrl.value.trim();
|
||||||
|
const type = dom.sourceType.value;
|
||||||
const description = dom.sourceDesc.value.trim();
|
const description = dom.sourceDesc.value.trim();
|
||||||
const is_server_source = dom.isServerSource.checked;
|
const is_server_source = dom.isServerSource.checked;
|
||||||
|
|
||||||
@@ -1790,7 +1800,7 @@
|
|||||||
const response = await fetch('/api/sources', {
|
const response = await fetch('/api/sources', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -38,15 +38,22 @@ async function checkAndFixDatabase() {
|
|||||||
console.log(`[Database Integrity] ✅ Missing tables created.`);
|
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 [promColumns] = await db.query("SHOW COLUMNS FROM prometheus_sources");
|
||||||
const promColumnNames = promColumns.map(c => c.Field);
|
const promColumnNames = promColumns.map(c => c.Field);
|
||||||
|
|
||||||
if (!promColumnNames.includes('is_server_source')) {
|
if (!promColumnNames.includes('is_server_source')) {
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column 'is_server_source' in 'prometheus_sources'. Adding it...`);
|
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");
|
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.`);
|
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
|
// Check for new columns in site_settings
|
||||||
const [columns] = await db.query("SHOW COLUMNS FROM site_settings");
|
const [columns] = await db.query("SHOW COLUMNS FROM site_settings");
|
||||||
const columnNames = columns.map(c => c.Field);
|
const columnNames = columns.map(c => c.Field);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const db = require('./db');
|
|||||||
const prometheusService = require('./prometheus-service');
|
const prometheusService = require('./prometheus-service');
|
||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const geoService = require('./geo-service');
|
const geoService = require('./geo-service');
|
||||||
|
const latencyService = require('./latency-service');
|
||||||
const checkAndFixDatabase = require('./db-integrity-check');
|
const checkAndFixDatabase = require('./db-integrity-check');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
@@ -451,7 +452,14 @@ app.get('/api/sources', async (req, res) => {
|
|||||||
// Test connectivity for each source
|
// Test connectivity for each source
|
||||||
const sourcesWithStatus = await Promise.all(rows.map(async (source) => {
|
const sourcesWithStatus = await Promise.all(rows.map(async (source) => {
|
||||||
try {
|
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 };
|
return { ...source, status: 'online', version: response };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ...source, status: 'offline', version: null };
|
return { ...source, status: 'offline', version: null };
|
||||||
@@ -466,15 +474,15 @@ app.get('/api/sources', async (req, res) => {
|
|||||||
|
|
||||||
// Add a new Prometheus source
|
// Add a new Prometheus source
|
||||||
app.post('/api/sources', requireAuth, async (req, res) => {
|
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) {
|
if (!name || !url) {
|
||||||
return res.status(400).json({ error: 'Name and URL are required' });
|
return res.status(400).json({ error: 'Name and URL are required' });
|
||||||
}
|
}
|
||||||
if (!/^https?:\/\//i.test(url)) url = 'http://' + url;
|
if (!/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||||
try {
|
try {
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
'INSERT INTO prometheus_sources (name, url, description, is_server_source) VALUES (?, ?, ?, ?)',
|
'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)]
|
[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]);
|
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;
|
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||||
try {
|
try {
|
||||||
await db.query(
|
await db.query(
|
||||||
'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ? WHERE id = ?',
|
'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ?, type = ? WHERE id = ?',
|
||||||
[name, url, description || '', is_server_source ? 1 : 0, req.params.id]
|
[name, url, description || '', is_server_source ? 1 : 0, type || 'prometheus', req.params.id]
|
||||||
);
|
);
|
||||||
// Clear network history cache
|
// Clear network history cache
|
||||||
await cache.del('network_history_all');
|
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
|
// Test connection to a Prometheus source
|
||||||
app.post('/api/sources/test', async (req, res) => {
|
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;
|
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||||
try {
|
try {
|
||||||
const version = await prometheusService.testConnection(url);
|
let result;
|
||||||
res.json({ status: 'ok', version });
|
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) {
|
} catch (err) {
|
||||||
res.status(400).json({ status: 'error', message: err.message });
|
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 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 {
|
return {
|
||||||
id: route.id,
|
id: route.id,
|
||||||
source: route.latency_source,
|
source: route.latency_source,
|
||||||
@@ -933,8 +955,9 @@ async function broadcastMetrics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check and fix database integrity on startup
|
// Start services
|
||||||
checkAndFixDatabase();
|
checkAndFixDatabase();
|
||||||
|
latencyService.start();
|
||||||
|
|
||||||
const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000;
|
const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000;
|
||||||
setInterval(broadcastMetrics, REFRESH_INT);
|
setInterval(broadcastMetrics, REFRESH_INT);
|
||||||
|
|||||||
77
server/latency-service.js
Normal file
77
server/latency-service.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user