diff --git a/public/index.html b/public/index.html index 073d7fa..6b57ff1 100644 --- a/public/index.html +++ b/public/index.html @@ -440,31 +440,48 @@ -

Blackbox Exporter & 延迟连线

-
- - -
-
-
- - -
-
- - -
-
-
- - +

Blackbox 延迟连线管理

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+ + +
+

已配置线路

+
+ +
暂无线路
+
+
- +
diff --git a/public/js/app.js b/public/js/app.js index b15f197..66a675c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -67,10 +67,12 @@ legendTx: document.getElementById('legendTx'), p95LabelText: document.getElementById('p95LabelText'), p95TypeSelect: document.getElementById('p95TypeSelect'), - blackboxSourceSelect: document.getElementById('blackboxSourceSelect'), - latencySourceInput: document.getElementById('latencySourceInput'), - latencyDestInput: document.getElementById('latencyDestInput'), - latencyTargetInput: document.getElementById('latencyTargetInput'), + routeSourceSelect: document.getElementById('routeSourceSelect'), + routeSourceInput: document.getElementById('routeSourceInput'), + routeDestInput: document.getElementById('routeDestInput'), + routeTargetInput: document.getElementById('routeTargetInput'), + btnAddRoute: document.getElementById('btnAddRoute'), + latencyRoutesList: document.getElementById('latencyRoutesList'), detailDiskTotal: document.getElementById('detailDiskTotal'), // Server Details Modal serverDetailModal: document.getElementById('serverDetailModal'), @@ -110,7 +112,7 @@ let currentSourceFilter = 'all'; let currentPage = 1; let pageSize = 20; - let currentLatency = null; + let currentLatencies = []; // Array of {id, source, dest, latency} let latencyTimer = null; // Load sort state from localStorage or use default @@ -168,6 +170,7 @@ // Site settings dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings); + dom.btnAddRoute.addEventListener('click', addLatencyRoute); // Auth password change if (dom.btnChangePassword) { @@ -309,10 +312,7 @@ dom.defaultThemeInput.value = window.SITE_SETTINGS.default_theme || 'dark'; dom.show95BandwidthInput.value = window.SITE_SETTINGS.show_95_bandwidth ? "1" : "0"; dom.p95TypeSelect.value = window.SITE_SETTINGS.p95_type || 'tx'; - // blackboxSourceSelect will be set after sources are fetched in updateSourceFilterOptions - dom.latencySourceInput.value = window.SITE_SETTINGS.latency_source || ''; - dom.latencyDestInput.value = window.SITE_SETTINGS.latency_dest || ''; - dom.latencyTargetInput.value = window.SITE_SETTINGS.latency_target || ''; + // Latency routes loaded separately in openSettings or on startup } loadSiteSettings(); @@ -495,7 +495,7 @@ try { const response = await fetch('/api/metrics/latency'); const data = await response.json(); - currentLatency = data.latency; + currentLatencies = data.routes || []; if (allServersData.length > 0) { updateMap2D(allServersData); } @@ -621,84 +621,6 @@ netTx: s.netTx })); - // Draw latency line if configured - if (window.SITE_SETTINGS && window.SITE_SETTINGS.latency_source && window.SITE_SETTINGS.latency_dest) { - const sourceName = window.SITE_SETTINGS.latency_source; - const destName = window.SITE_SETTINGS.latency_dest; - - // Coordinates for countries (fallback to common ones or try to find in geoJSON) - const countryCoords = { - 'China': [116.4074, 39.9042], - 'United States': [-95.7129, 37.0902], - 'Japan': [138.2529, 36.2048], - 'Singapore': [103.8198, 1.3521], - 'Germany': [10.4515, 51.1657], - 'United Kingdom': [-3.436, 55.3781], - 'France': [2.2137, 46.2276], - 'Hong Kong': [114.1694, 22.3193], - 'Taiwan': [120.9605, 23.6978], - 'Korea': [127.7669, 35.9078] - }; - - const getCoords = (name) => { - if (countryCoords[name]) return countryCoords[name]; - // Try to find in current server data - const s = servers.find(sv => sv.countryName === name || sv.country === name); - if (s && s.lng && s.lat) return [s.lng, s.lat]; - return null; - }; - - const startCoords = getCoords(sourceName); - const endCoords = getCoords(destName); - - if (startCoords && endCoords) { - const lineData = [{ - fromName: sourceName, - toName: destName, - coords: [startCoords, endCoords], - latency: currentLatency - }]; - - const lineSeries = { - type: 'lines', - coordinateSystem: 'geo', - geoIndex: 0, - zlevel: 2, - effect: { - show: true, - period: 4, - trailLength: 0.1, - color: 'rgba(99, 102, 241, 0.8)', - symbol: 'arrow', - symbolSize: 6 - }, - lineStyle: { - color: 'rgba(99, 102, 241, 0.3)', - width: 2, - curveness: 0.2 - }, - tooltip: { - formatter: () => { - const latVal = (currentLatency !== null && currentLatency !== undefined) ? `${currentLatency.toFixed(2)} ms` : '测量中...'; - return ` -
-
${sourceName} ↔ ${destName}
-
延时: ${latVal}
-
- `; - } - }, - data: lineData - }; - - window.latencySeries = lineSeries; - } else { - window.latencySeries = null; - } - } else { - window.latencySeries = null; - } - // Combine all series const finalSeries = [ { @@ -716,8 +638,70 @@ } ]; - if (window.latencySeries) { - finalSeries.push(window.latencySeries); + // Add latency routes if configured + if (currentLatencies && currentLatencies.length > 0) { + const countryCoords = { + 'China': [116.4074, 39.9042], + 'United States': [-95.7129, 37.0902], + 'Japan': [138.2529, 36.2048], + 'Singapore': [103.8198, 1.3521], + 'Germany': [10.4515, 51.1657], + 'United Kingdom': [-3.436, 55.3781], + 'France': [2.2137, 46.2276], + 'Hong Kong': [114.1694, 22.3193], + 'Taiwan': [120.9605, 23.6978], + 'Korea': [127.7669, 35.9078] + }; + + const getCoords = (name) => { + if (countryCoords[name]) return countryCoords[name]; + const s = servers.find(sv => sv.countryName === name || sv.country === name); + if (s && s.lng && s.lat) return [s.lng, s.lat]; + return null; + }; + + currentLatencies.forEach((route, index) => { + const startCoords = getCoords(route.source); + const endCoords = getCoords(route.dest); + + if (startCoords && endCoords) { + finalSeries.push({ + type: 'lines', + coordinateSystem: 'geo', + zlevel: 2, + latencyLine: true, + effect: { + show: true, + period: 4, + trailLength: 0.1, + color: 'rgba(99, 102, 241, 0.8)', + symbol: 'arrow', + symbolSize: 6 + }, + lineStyle: { + color: 'rgba(99, 102, 241, 0.3)', + width: 2, + curveness: 0.2 + (index * 0.1) // Slightly different curves for clarity + }, + tooltip: { + formatter: () => { + const latVal = (route.latency !== null && route.latency !== undefined) ? `${route.latency.toFixed(2)} ms` : '测量中...'; + return ` +
+
${route.source} ↔ ${route.dest}
+
延时: ${latVal}
+
+ `; + } + }, + data: [{ + fromName: route.source, + toName: route.dest, + coords: [startCoords, endCoords] + }] + }); + } + }); } myMap2D.setOption({ @@ -1307,6 +1291,7 @@ function openSettings() { dom.settingsModal.classList.add('active'); loadSources(); + loadLatencyRoutes(); } function closeSettings() { @@ -1446,11 +1431,7 @@ logo_url: dom.logoUrlInput.value.trim(), default_theme: dom.defaultThemeInput.value, show_95_bandwidth: dom.show95BandwidthInput.value === "1" ? 1 : 0, - p95_type: dom.p95TypeSelect.value, - blackbox_source_id: dom.blackboxSourceSelect.value ? parseInt(dom.blackboxSourceSelect.value) : null, - latency_source: dom.latencySourceInput.value.trim(), - latency_dest: dom.latencyDestInput.value.trim(), - latency_target: dom.latencyTargetInput.value.trim() + p95_type: dom.p95TypeSelect.value }; dom.btnSaveSiteSettings.disabled = true; @@ -1475,10 +1456,104 @@ showSiteMessage(`保存失败: ${err.message}`, 'error'); } finally { dom.btnSaveSiteSettings.disabled = false; - dom.btnSaveSiteSettings.textContent = '保存设置'; + dom.btnSaveSiteSettings.textContent = '保存基础设置'; } } + // ---- Latency Routes ---- + async function loadLatencyRoutes() { + try { + const response = await fetch('/api/latency-routes'); + const routes = await response.json(); + renderLatencyRoutes(routes); + } catch (err) { + console.error('Error loading latency routes:', err); + } + } + + function renderLatencyRoutes(routes) { + if (routes.length === 0) { + dom.latencyRoutesList.innerHTML = `
暂无线路
`; + return; + } + + dom.latencyRoutesList.innerHTML = routes.map(route => ` +
+
+
+ ${escapeHtml(route.latency_source)} ↔ ${escapeHtml(route.latency_dest)} +
+
+ 数据源: ${escapeHtml(route.source_name || '已删除')} + 目标: ${escapeHtml(route.latency_target)} +
+
+ +
+ `).join(''); + } + + async function addLatencyRoute() { + if (!user) { + showSiteMessage('请先登录及进行身份验证', 'error'); + openLoginModal(); + return; + } + + const source_id = dom.routeSourceSelect.value; + const latency_source = dom.routeSourceInput.value.trim(); + const latency_dest = dom.routeDestInput.value.trim(); + const latency_target = dom.routeTargetInput.value.trim(); + + if (!source_id || !latency_source || !latency_dest || !latency_target) { + showSiteMessage('请填写完整的线路信息', 'error'); + return; + } + + try { + const response = await fetch('/api/latency-routes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_id: parseInt(source_id), latency_source, latency_dest, latency_target }) + }); + + if (response.ok) { + showSiteMessage('线路添加成功', 'success'); + dom.routeSourceInput.value = ''; + dom.routeDestInput.value = ''; + dom.routeTargetInput.value = ''; + loadLatencyRoutes(); + fetchLatency(); + } else { + if (response.status === 401) openLoginModal(); + } + } catch (err) { + console.error('Error adding latency route:', err); + } + } + + window.deleteLatencyRoute = async function(id) { + if (!user) { + showSiteMessage('请登录后再操作', 'error'); + openLoginModal(); + return; + } + + if (!confirm('确定要删除这条延迟线路吗?')) return; + + try { + const response = await fetch(`/api/latency-routes/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadLatencyRoutes(); + fetchLatency(); + } else { + if (response.status === 401) openLoginModal(); + } + } catch (err) { + console.error('Error deleting latency route:', err); + } + }; + function showSiteMessage(text, type) { dom.siteSettingsMessage.textContent = text; dom.siteSettingsMessage.className = `form-message ${type}`; @@ -1575,15 +1650,11 @@ } } - if (dom.blackboxSourceSelect) { - let html = ''; - sources.forEach(source => { - html += ``; - }); - dom.blackboxSourceSelect.innerHTML = html; - if (window.SITE_SETTINGS && window.SITE_SETTINGS.blackbox_source_id) { - dom.blackboxSourceSelect.value = window.SITE_SETTINGS.blackbox_source_id; - } + if (dom.routeSourceSelect) { + const currentVal = dom.routeSourceSelect.value; + dom.routeSourceSelect.innerHTML = '' + + sources.map(s => ``).join(''); + dom.routeSourceSelect.value = currentVal; } } diff --git a/server/db-integrity-check.js b/server/db-integrity-check.js index 2c1317b..dbb3f8f 100644 --- a/server/db-integrity-check.js +++ b/server/db-integrity-check.js @@ -14,7 +14,8 @@ const REQUIRED_TABLES = [ 'prometheus_sources', 'site_settings', 'traffic_stats', - 'server_locations' + 'server_locations', + 'latency_routes' ]; async function checkAndFixDatabase() { @@ -145,6 +146,18 @@ async function createTable(tableName) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); break; + case 'latency_routes': + await db.query(` + CREATE TABLE IF NOT EXISTS latency_routes ( + id INT AUTO_INCREMENT PRIMARY KEY, + source_id INT NOT NULL, + latency_source VARCHAR(100) NOT NULL, + latency_dest VARCHAR(100) NOT NULL, + latency_target VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + break; case 'server_locations': await db.query(` CREATE TABLE IF NOT EXISTS server_locations ( diff --git a/server/index.js b/server/index.js index ae6c1cb..aa7355f 100644 --- a/server/index.js +++ b/server/index.js @@ -842,24 +842,69 @@ app.get('*', (req, res, next) => { }); -// Get latency for A-B connection +// ==================== Latency Routes CRUD ==================== + +app.get('/api/latency-routes', async (req, res) => { + try { + const [rows] = await db.query(` + SELECT r.*, s.name as source_name + FROM latency_routes r + LEFT JOIN prometheus_sources s ON r.source_id = s.id + ORDER BY r.created_at DESC + `); + res.json(rows); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch latency routes' }); + } +}); + +app.post('/api/latency-routes', requireAuth, async (req, res) => { + const { source_id, latency_source, latency_dest, latency_target } = req.body; + try { + await db.query('INSERT INTO latency_routes (source_id, latency_source, latency_dest, latency_target) VALUES (?, ?, ?, ?)', [source_id, latency_source, latency_dest, latency_target]); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Failed to add latency route' }); + } +}); + +app.delete('/api/latency-routes/:id', requireAuth, async (req, res) => { + try { + await db.query('DELETE FROM latency_routes WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Failed to delete latency route' }); + } +}); + +// ==================== Metrics Latency ==================== + app.get('/api/metrics/latency', async (req, res) => { try { - const [settings] = await db.query('SELECT blackbox_source_id, latency_target FROM site_settings WHERE id = 1'); - if (settings.length === 0 || !settings[0].blackbox_source_id || !settings[0].latency_target) { - return res.json({ latency: null }); - } + const [routes] = await db.query(` + SELECT r.*, s.url + FROM latency_routes r + JOIN prometheus_sources s ON r.source_id = s.id + `); - // Lookup source URL from the source ID - const [sources] = await db.query('SELECT url FROM prometheus_sources WHERE id = ?', [settings[0].blackbox_source_id]); - if (sources.length === 0) { - return res.json({ latency: null }); + if (routes.length === 0) { + // Return empty routes array instead of null for consistency + return res.json({ routes: [] }); } - const latency = await prometheusService.getLatency(sources[0].url, settings[0].latency_target); - res.json({ latency }); + const results = await Promise.all(routes.map(async (route) => { + const latency = await prometheusService.getLatency(route.url, route.latency_target); + return { + id: route.id, + source: route.latency_source, + dest: route.latency_dest, + latency: latency + }; + })); + + res.json({ routes: results }); } catch (err) { - console.error('Error fetching latency:', err); + console.error('Error fetching latencies:', err); res.status(500).json({ error: 'Failed to fetch latency' }); } });