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 & 延迟连线
-
-
-
-
-
-
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' });
}
});