diff --git a/public/css/style.css b/public/css/style.css index 9839d6f..3d59ba0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1604,6 +1604,7 @@ input:checked+.slider:before { display: flex; gap: 12px; margin-bottom: 12px; + align-items: flex-end; } .form-group { @@ -1776,6 +1777,29 @@ input:checked+.slider:before { letter-spacing: 0.05em; } +.source-type-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.source-type-badge.type-server { + background: rgba(99, 102, 241, 0.1); + color: #818cf8; + border: 1px solid rgba(99, 102, 241, 0.2); +} + +.source-type-badge.type-other { + background: rgba(139, 92, 246, 0.1); + color: #a78bfa; + border: 1px solid rgba(139, 92, 246, 0.2); +} + .source-status-online { background: rgba(16, 185, 129, 0.1); color: var(--accent-emerald); diff --git a/public/index.html b/public/index.html index 5831b62..7c2831e 100644 --- a/public/index.html +++ b/public/index.html @@ -376,6 +376,12 @@ +
+ +
@@ -434,6 +440,29 @@
+

Blackbox Exporter & 延迟连线

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
diff --git a/public/js/app.js b/public/js/app.js index 991dcf9..442331e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -36,6 +36,7 @@ sourceDesc: document.getElementById('sourceDesc'), btnTest: document.getElementById('btnTest'), btnAdd: document.getElementById('btnAdd'), + isServerSource: document.getElementById('isServerSource'), formMessage: document.getElementById('formMessage'), sourceItems: document.getElementById('sourceItems'), // Site Settings @@ -66,6 +67,10 @@ 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'), detailDiskTotal: document.getElementById('detailDiskTotal'), // Server Details Modal serverDetailModal: document.getElementById('serverDetailModal'), @@ -105,6 +110,8 @@ let currentSourceFilter = 'all'; let currentPage = 1; let pageSize = 20; + let currentLatency = null; + let latencyTimer = null; // Load sort state from localStorage or use default let currentSort = { column: 'up', direction: 'desc' }; @@ -285,6 +292,7 @@ // Start data fetching fetchMetrics(); fetchNetworkHistory(); + fetchLatency(); // Site settings if (window.SITE_SETTINGS) { @@ -301,6 +309,10 @@ 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 || ''; } loadSiteSettings(); @@ -308,6 +320,7 @@ // setInterval(fetchMetrics, REFRESH_INTERVAL); - Now using WebSockets initWebSocket(); setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL); + setInterval(fetchLatency, REFRESH_INTERVAL); } // ---- Real-time WebSocket ---- @@ -476,6 +489,19 @@ } } + async function fetchLatency() { + try { + const response = await fetch('/api/metrics/latency'); + const data = await response.json(); + currentLatency = data.latency; + if (allServersData.length > 0) { + updateMap2D(allServersData); + } + } catch (err) { + console.error('Error fetching latency:', err); + } + } + // ---- Global 2D Map ---- async function initMap2D() { if (!dom.globeContainer) return; @@ -577,7 +603,6 @@ } }); } - function updateMap2D(servers) { if (!myMap2D) return; @@ -594,13 +619,96 @@ 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 + }; + + // Add or update line series + const options = myMap2D.getOption(); + const series = options.series; + + // Filter out existing latency lines to update + const newSeries = series.filter(s => s.type !== 'lines' || !s.latencyLine); + lineSeries.latencyLine = true; + newSeries.push(lineSeries); + + myMap2D.setOption({ series: newSeries }); + } + } + myMap2D.setOption({ series: [{ coordinateSystem: 'geo', geoIndex: 0, data: geoData }] - }); + }, { replaceMerge: ['series'] }); // Update footer stats if (dom.globeTotalNodes) dom.globeTotalNodes.textContent = geoData.length; @@ -1324,7 +1432,11 @@ 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 + 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() }; dom.btnSaveSiteSettings.disabled = true; @@ -1434,18 +1546,30 @@ } function updateSourceFilterOptions(sources) { - if (!dom.sourceFilter) return; - const current = dom.sourceFilter.value; - let html = ''; - sources.forEach(source => { - html += ``; - }); - dom.sourceFilter.innerHTML = html; - if (sources.some(s => s.name === current)) { - dom.sourceFilter.value = current; - } else { - dom.sourceFilter.value = 'all'; - currentSourceFilter = 'all'; + if (dom.sourceFilter) { + const current = dom.sourceFilter.value; + let html = ''; + sources.forEach(source => { + html += ``; + }); + dom.sourceFilter.innerHTML = html; + if (sources.some(s => s.name === current)) { + dom.sourceFilter.value = current; + } else { + dom.sourceFilter.value = 'all'; + currentSourceFilter = 'all'; + } + } + + 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; + } } } @@ -1475,6 +1599,9 @@ ${source.status === 'online' ? '在线' : '离线'} + + ${source.is_server_source ? '服务器看板' : '独立数据源'} +
${escapeHtml(source.url)}
${source.description ? `
${escapeHtml(source.description)}
` : ''} @@ -1533,6 +1660,7 @@ const name = dom.sourceName.value.trim(); const url = dom.sourceUrl.value.trim(); const description = dom.sourceDesc.value.trim(); + const is_server_source = dom.isServerSource.checked; if (!name || !url) { showMessage('请填写名称和URL', 'error'); @@ -1546,7 +1674,7 @@ const response = await fetch('/api/sources', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, url, description }) + body: JSON.stringify({ name, url, description, is_server_source }) }); if (response.ok) { diff --git a/public/js/chart.js b/public/js/chart.js index 79b5899..e525f2e 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -9,13 +9,13 @@ class AreaChart { this.data = { timestamps: [], rx: [], tx: [] }; this.animProgress = 0; this.animFrame = null; - this.showP95 = false; + this.showP95 = false; this.showRx = true; this.showTx = true; this.p95Type = 'tx'; // 'tx', 'rx', 'both' this.dpr = window.devicePixelRatio || 1; this.padding = { top: 20, right: 16, bottom: 32, left: 56 }; - + this.currentMaxVal = 0; this.prevMaxVal = 0; @@ -38,7 +38,7 @@ class AreaChart { setData(data) { if (!data || !data.timestamps) return; - + // Store old data for smooth transition before updating this.data // Only clone if there is data to clone; otherwise use empty set if (this.data && this.data.timestamps && this.data.timestamps.length > 0) { @@ -50,7 +50,7 @@ class AreaChart { } else { this.prevData = { timestamps: [], rx: [], tx: [] }; } - + // Smoothly transition max value context too this.prevMaxVal = this.currentMaxVal || 0; @@ -72,19 +72,19 @@ class AreaChart { // Refresh currentMaxVal target for interpolation in draw() let rawMax = 1024; for (let i = 0; i < this.data.rx.length; i++) { - if (this.showRx) rawMax = Math.max(rawMax, this.data.rx[i] || 0); - if (this.showTx) rawMax = Math.max(rawMax, this.data.tx[i] || 0); + if (this.showRx) rawMax = Math.max(rawMax, this.data.rx[i] || 0); + if (this.showTx) rawMax = Math.max(rawMax, this.data.tx[i] || 0); } this.currentMaxVal = rawMax; // Calculate P95 (95th percentile) let combined = []; if (this.p95Type === 'tx') { - combined = data.tx.map(t => t || 0); + combined = data.tx.map(t => t || 0); } else if (this.p95Type === 'rx') { - combined = data.rx.map(r => r || 0); + combined = data.rx.map(r => r || 0); } else { - combined = data.tx.map((t, i) => (t || 0) + (data.rx[i] || 0)); + combined = data.tx.map((t, i) => (t || 0) + (data.rx[i] || 0)); } if (combined.length > 0) { @@ -141,12 +141,12 @@ class AreaChart { // Determine consistent unit based on max data value let maxDataVal = 1024; if (this.prevMaxVal && this.animProgress < 1) { - // Interpolate the max value context to keep vertical scale smooth - maxDataVal = this.prevMaxVal + (this.currentMaxVal - this.prevMaxVal) * (this.animProgress || 0); + // Interpolate the max value context to keep vertical scale smooth + maxDataVal = this.prevMaxVal + (this.currentMaxVal - this.prevMaxVal) * (this.animProgress || 0); } else { - maxDataVal = this.currentMaxVal; + maxDataVal = this.currentMaxVal; } - + const k = 1024; const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s']; let unitIdx = Math.floor(Math.log(Math.max(1, maxDataVal)) / Math.log(k)); @@ -158,7 +158,7 @@ class AreaChart { // Use 1.15 cushion const rawValInUnit = (maxDataVal * 1.15) / unitFactor; let niceMaxInUnit; - + if (rawValInUnit <= 1) niceMaxInUnit = 1; else if (rawValInUnit <= 2) niceMaxInUnit = 2; else if (rawValInUnit <= 5) niceMaxInUnit = 5; @@ -200,7 +200,7 @@ class AreaChart { ctx.fillStyle = '#5a6380'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; - + // Format: "X.X MB/s" or "X MB/s" const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + unitLabel; ctx.fillText(label, p.left - 10, y + 3); @@ -247,14 +247,14 @@ class AreaChart { ctx.moveTo(p.left, p95Y); ctx.lineTo(p.left + chartW, p95Y); ctx.stroke(); - + // P95 label background const label = '95计费: ' + (window.formatBandwidth ? window.formatBandwidth(this.p95) : this.p95.toFixed(2)); ctx.font = 'bold 11px "JetBrains Mono", monospace'; const metrics = ctx.measureText(label); ctx.fillStyle = 'rgba(244, 63, 94, 0.15)'; ctx.fillRect(p.left + 8, p95Y - 20, metrics.width + 12, 18); - + // P95 label text ctx.fillStyle = '#f43f5e'; ctx.textAlign = 'left'; @@ -274,7 +274,7 @@ class AreaChart { ctx.beginPath(); ctx.moveTo(getX(0), getY(values[0] || 0, getPVal(0))); for (let i = 1; i < len; i++) { - const currY = getY(values[i] || 0, getPVal(i)); + const currY = getY(values[i] || 0, getPVal(i)); if (useSimple) { ctx.lineTo(getX(i), currY); } else { @@ -299,7 +299,7 @@ class AreaChart { ctx.beginPath(); ctx.moveTo(getX(0), getY(values[0] || 0, getPVal(0))); for (let i = 1; i < len; i++) { - const currY = getY(values[i] || 0, getPVal(i)); + const currY = getY(values[i] || 0, getPVal(i)); if (useSimple) { ctx.lineTo(getX(i), currY); } else { @@ -331,7 +331,7 @@ class MetricChart { this.dpr = window.devicePixelRatio || 1; this.padding = { top: 10, right: 10, bottom: 20, left: 60 }; this.animProgress = 0; - + this.prevMaxVal = 0; this.currentMaxVal = 0; @@ -357,27 +357,27 @@ class MetricChart { setData(data) { if (this.data && this.data.values && this.data.values.length > 0) { - this.prevData = JSON.parse(JSON.stringify(this.data)); + this.prevData = JSON.parse(JSON.stringify(this.data)); } else { - this.prevData = { timestamps: [], values: [], series: null }; + this.prevData = { timestamps: [], values: [], series: null }; } - + this.prevMaxVal = this.currentMaxVal || 0; this.data = data || { timestamps: [], values: [], series: null }; // Target max if (this.data.series) { - this.currentMaxVal = 100; + this.currentMaxVal = 100; } else { - const raw = Math.max(...(this.data.values || []), 0.1); - if (this.unit === '%' && raw <= 100) { - if (raw > 80) this.currentMaxVal = 100; - else if (raw > 40) this.currentMaxVal = 80; - else if (raw > 20) this.currentMaxVal = 50; - else this.currentMaxVal = 25; - } else { - this.currentMaxVal = raw * 1.25; - } + const raw = Math.max(...(this.data.values || []), 0.1); + if (this.unit === '%' && raw <= 100) { + if (raw > 80) this.currentMaxVal = 100; + else if (raw > 40) this.currentMaxVal = 80; + else if (raw > 20) this.currentMaxVal = 50; + else this.currentMaxVal = 25; + } else { + this.currentMaxVal = raw * 1.25; + } } this.animate(); @@ -425,8 +425,8 @@ class MetricChart { const xStep = chartW / (len - 1); const getX = (i) => p.left + i * xStep; const getY = (val, prevVal = 0) => { - const actualVal = prevVal + (val - prevVal) * this.animProgress; - return p.top + chartH - (actualVal / (maxVal || 1)) * chartH; + const actualVal = prevVal + (val - prevVal) * this.animProgress; + return p.top + chartH - (actualVal / (maxVal || 1)) * chartH; }; // Grid @@ -438,135 +438,135 @@ class MetricChart { ctx.moveTo(p.left, y); ctx.lineTo(p.left + chartW, y); ctx.stroke(); - + const v = (maxVal * (1 - i / 3)); ctx.fillStyle = '#5a6380'; ctx.font = '9px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; - + let label = ''; if (this.unit === 'B/s' || this.unit === 'B') { - const isRate = this.unit === 'B/s'; - if (window.formatBandwidth && isRate) { - label = window.formatBandwidth(v); - } else if (window.formatBytes) { - label = window.formatBytes(v) + (isRate ? '/s' : ''); - } else { - label = v.toFixed(0) + this.unit; - } + const isRate = this.unit === 'B/s'; + if (window.formatBandwidth && isRate) { + label = window.formatBandwidth(v); + } else if (window.formatBytes) { + label = window.formatBytes(v) + (isRate ? '/s' : ''); + } else { + label = v.toFixed(0) + this.unit; + } } else { - label = (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(v < 10 && v > 0 ? 1 : 0)) + this.unit; + label = (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(v < 10 && v > 0 ? 1 : 0)) + this.unit; } ctx.fillText(label, p.left - 8, y + 3); } if (series) { - // Draw Stacked Area - const modes = [ - { name: 'idle', color: 'rgba(34, 197, 94, 0.4)', stroke: '#22c55e' }, // Green - { name: 'other', color: 'rgba(168, 85, 247, 0.4)', stroke: '#a855f7' }, // Purple - { name: 'irq', color: 'rgba(249, 115, 22, 0.4)', stroke: '#f97316' }, // Orange - { name: 'iowait', color: 'rgba(239, 68, 68, 0.4)', stroke: '#ef4444' }, // Red - { name: 'system', color: 'rgba(234, 179, 8, 0.4)', stroke: '#eab308' }, // Yellow - { name: 'user', color: 'rgba(99, 102, 241, 0.4)', stroke: '#6366f1' } // Indigo - ]; + // Draw Stacked Area + const modes = [ + { name: 'idle', color: 'rgba(34, 197, 94, 0.4)', stroke: '#22c55e' }, // Green + { name: 'other', color: 'rgba(168, 85, 247, 0.4)', stroke: '#a855f7' }, // Purple + { name: 'irq', color: 'rgba(249, 115, 22, 0.4)', stroke: '#f97316' }, // Orange + { name: 'iowait', color: 'rgba(239, 68, 68, 0.4)', stroke: '#ef4444' }, // Red + { name: 'system', color: 'rgba(234, 179, 8, 0.4)', stroke: '#eab308' }, // Yellow + { name: 'user', color: 'rgba(99, 102, 241, 0.4)', stroke: '#6366f1' } // Indigo + ]; - let currentBase = new Array(len).fill(0); - let prevBase = new Array(len).fill(0); - - modes.forEach(mode => { - const vals = series[mode.name]; - if (!vals) return; - - const prevVals = (this.prevData && this.prevData.series) ? this.prevData.series[mode.name] : null; - const getPVal = (arr, idx) => (arr && idx < arr.length) ? arr[idx] : 0; + let currentBase = new Array(len).fill(0); + let prevBase = new Array(len).fill(0); - ctx.beginPath(); - ctx.moveTo(getX(0), getY(currentBase[0] + vals[0], getPVal(prevBase, 0) + getPVal(prevVals, 0))); - for (let i = 1; i < len; i++) { - ctx.lineTo(getX(i), getY(currentBase[i] + vals[i], getPVal(prevBase, i) + getPVal(prevVals, i))); - } - ctx.lineTo(getX(len - 1), getY(currentBase[len - 1], getPVal(prevBase, len - 1))); - for (let i = len - 1; i >= 0; i--) { - ctx.lineTo(getX(i), getY(currentBase[i], getPVal(prevBase, i))); - } - ctx.closePath(); - ctx.fillStyle = mode.color; - ctx.fill(); + modes.forEach(mode => { + const vals = series[mode.name]; + if (!vals) return; - // Stroke - ctx.beginPath(); - ctx.moveTo(getX(0), getY(currentBase[0] + vals[0], getPVal(prevBase, 0) + getPVal(prevVals, 0))); - for (let i = 1; i < len; i++) { - ctx.lineTo(getX(i), getY(currentBase[i] + vals[i], getPVal(prevBase, i) + getPVal(prevVals, i))); - } - ctx.strokeStyle = mode.stroke; - ctx.lineWidth = 1; - ctx.stroke(); - - // Update boxes for next series - for (let i = 0; i < len; i++) { - currentBase[i] += vals[i]; - if (prevBase) prevBase[i] = (prevBase[i] || 0) + getPVal(prevVals, i); - } - }); - - // Add Legend at bottom right (moved up slightly) - ctx.font = '9px sans-serif'; - ctx.textAlign = 'right'; - let lx = w - 10; - let ly = h - 20; // Increased padding from bottom - [...modes].reverse().forEach(m => { - ctx.fillStyle = m.stroke; - ctx.fillRect(lx - 10, ly - 8, 8, 8); - ctx.fillStyle = '#5a6380'; - ctx.fillText(m.name.charAt(0).toUpperCase() + m.name.slice(1), lx - 15, ly - 1); - lx -= 70; // Increased gap for safety - }); - - } else { - const useSimple = len > 250; - const prevVals = this.prevData ? this.prevData.values : null; - const getPVal = (i) => (prevVals && i < prevVals.length) ? prevVals[i] : 0; + const prevVals = (this.prevData && this.prevData.series) ? this.prevData.series[mode.name] : null; + const getPVal = (arr, idx) => (arr && idx < arr.length) ? arr[idx] : 0; ctx.beginPath(); - ctx.moveTo(getX(0), getY(values[0], getPVal(0))); + ctx.moveTo(getX(0), getY(currentBase[0] + vals[0], getPVal(prevBase, 0) + getPVal(prevVals, 0))); for (let i = 1; i < len; i++) { - const currY = getY(values[i], getPVal(i)); - if (useSimple) { - ctx.lineTo(getX(i), currY); - } else { - const prevX = getX(i - 1); - const currX = getX(i); - const prevY = getY(values[i - 1], getPVal(i - 1)); - const midX = (prevX + currX) / 2; - ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); - } + ctx.lineTo(getX(i), getY(currentBase[i] + vals[i], getPVal(prevBase, i) + getPVal(prevVals, i))); } - + ctx.lineTo(getX(len - 1), getY(currentBase[len - 1], getPVal(prevBase, len - 1))); + for (let i = len - 1; i >= 0; i--) { + ctx.lineTo(getX(i), getY(currentBase[i], getPVal(prevBase, i))); + } + ctx.closePath(); + ctx.fillStyle = mode.color; + ctx.fill(); + // Stroke - ctx.strokeStyle = '#6366f1'; - ctx.lineWidth = 2; - ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(getX(0), getY(currentBase[0] + vals[0], getPVal(prevBase, 0) + getPVal(prevVals, 0))); + for (let i = 1; i < len; i++) { + ctx.lineTo(getX(i), getY(currentBase[i] + vals[i], getPVal(prevBase, i) + getPVal(prevVals, i))); + } + ctx.strokeStyle = mode.stroke; + ctx.lineWidth = 1; ctx.stroke(); - // Fill - ctx.lineTo(getX(len - 1), p.top + chartH); - ctx.lineTo(getX(0), p.top + chartH); - ctx.closePath(); - const grad = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); - grad.addColorStop(0, 'rgba(99, 102, 241, 0.15)'); - grad.addColorStop(1, 'rgba(99, 102, 241, 0)'); - ctx.fillStyle = grad; - ctx.fill(); - - // Last point pulse - const lastX = getX(len - 1); - const lastY = getY(values[len - 1]); - ctx.beginPath(); - ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); - ctx.fillStyle = '#6366f1'; - ctx.fill(); + // Update boxes for next series + for (let i = 0; i < len; i++) { + currentBase[i] += vals[i]; + if (prevBase) prevBase[i] = (prevBase[i] || 0) + getPVal(prevVals, i); + } + }); + + // Add Legend at bottom right (moved up slightly) + ctx.font = '9px sans-serif'; + ctx.textAlign = 'right'; + let lx = w - 10; + let ly = h - 20; // Increased padding from bottom + [...modes].reverse().forEach(m => { + ctx.fillStyle = m.stroke; + ctx.fillRect(lx - 10, ly - 8, 8, 8); + ctx.fillStyle = '#5a6380'; + ctx.fillText(m.name.charAt(0).toUpperCase() + m.name.slice(1), lx - 15, ly - 1); + lx -= 70; // Increased gap for safety + }); + + } else { + const useSimple = len > 250; + const prevVals = this.prevData ? this.prevData.values : null; + const getPVal = (i) => (prevVals && i < prevVals.length) ? prevVals[i] : 0; + + ctx.beginPath(); + ctx.moveTo(getX(0), getY(values[0], getPVal(0))); + for (let i = 1; i < len; i++) { + const currY = getY(values[i], getPVal(i)); + if (useSimple) { + ctx.lineTo(getX(i), currY); + } else { + const prevX = getX(i - 1); + const currX = getX(i); + const prevY = getY(values[i - 1], getPVal(i - 1)); + const midX = (prevX + currX) / 2; + ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); + } + } + + // Stroke + ctx.strokeStyle = '#6366f1'; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Fill + ctx.lineTo(getX(len - 1), p.top + chartH); + ctx.lineTo(getX(0), p.top + chartH); + ctx.closePath(); + const grad = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); + grad.addColorStop(0, 'rgba(99, 102, 241, 0.15)'); + grad.addColorStop(1, 'rgba(99, 102, 241, 0)'); + ctx.fillStyle = grad; + ctx.fill(); + + // Last point pulse + const lastX = getX(len - 1); + const lastY = getY(values[len - 1]); + ctx.beginPath(); + ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); + ctx.fillStyle = '#6366f1'; + ctx.fill(); } } diff --git a/server/db-integrity-check.js b/server/db-integrity-check.js index c95202e..dbcd422 100644 --- a/server/db-integrity-check.js +++ b/server/db-integrity-check.js @@ -37,6 +37,15 @@ async function checkAndFixDatabase() { console.log(`[Database Integrity] ✅ Missing tables created.`); } + // Check for is_server_source 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.`); + } + // Check for new columns in site_settings const [columns] = await db.query("SHOW COLUMNS FROM site_settings"); const columnNames = columns.map(c => c.Field); @@ -50,6 +59,26 @@ async function checkAndFixDatabase() { await db.query("ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"); console.log(`[Database Integrity] ✅ Column 'p95_type' added.`); } + if (!columnNames.includes('blackbox_source_id')) { + console.log(`[Database Integrity] ⚠️ Missing column 'blackbox_source_id' in 'site_settings'. Adding it...`); + await db.query("ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type"); + console.log(`[Database Integrity] ✅ Column 'blackbox_source_id' added.`); + } + if (!columnNames.includes('latency_source')) { + console.log(`[Database Integrity] ⚠️ Missing column 'latency_source' in 'site_settings'. Adding it...`); + await db.query("ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_url"); + console.log(`[Database Integrity] ✅ Column 'latency_source' added.`); + } + if (!columnNames.includes('latency_dest')) { + console.log(`[Database Integrity] ⚠️ Missing column 'latency_dest' in 'site_settings'. Adding it...`); + await db.query("ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source"); + console.log(`[Database Integrity] ✅ Column 'latency_dest' added.`); + } + if (!columnNames.includes('latency_target')) { + console.log(`[Database Integrity] ⚠️ Missing column 'latency_target' in 'site_settings'. Adding it...`); + await db.query("ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest"); + console.log(`[Database Integrity] ✅ Column 'latency_target' added.`); + } } catch (err) { console.error('[Database Integrity] ❌ Error checking integrity:', err.message); } @@ -91,6 +120,10 @@ async function createTable(tableName) { default_theme VARCHAR(20) DEFAULT 'dark', show_95_bandwidth TINYINT(1) DEFAULT 0, p95_type VARCHAR(20) DEFAULT 'tx', + blackbox_source_id INT, + latency_source VARCHAR(100), + latency_dest VARCHAR(100), + latency_target VARCHAR(255), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); diff --git a/server/index.js b/server/index.js index 9472088..ae6c1cb 100644 --- a/server/index.js +++ b/server/index.js @@ -264,6 +264,10 @@ app.post('/api/setup/init', async (req, res) => { default_theme VARCHAR(20) DEFAULT 'dark', show_95_bandwidth TINYINT(1) DEFAULT 0, p95_type VARCHAR(20) DEFAULT 'tx', + blackbox_source_id INT, + latency_source VARCHAR(100), + latency_dest VARCHAR(100), + latency_target VARCHAR(255), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); @@ -403,7 +407,11 @@ const serveIndex = async (req, res) => { page_name: '数据可视化展示大屏', title: '数据可视化展示大屏', logo_url: null, - default_theme: 'dark' + default_theme: 'dark', + blackbox_source_id: null, + latency_source: null, + latency_dest: null, + latency_target: null }; if (isDbInitialized) { @@ -439,7 +447,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false })); // Get all Prometheus sources app.get('/api/sources', async (req, res) => { try { - const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY created_at DESC'); + const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY is_server_source DESC, created_at DESC'); // Test connectivity for each source const sourcesWithStatus = await Promise.all(rows.map(async (source) => { try { @@ -458,15 +466,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 } = req.body; + let { name, url, description, is_server_source } = 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) VALUES (?, ?, ?)', - [name, url, description || ''] + 'INSERT INTO prometheus_sources (name, url, description, is_server_source) VALUES (?, ?, ?, ?)', + [name, url, description || '', is_server_source === undefined ? 1 : (is_server_source ? 1 : 0)] ); const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]); @@ -482,12 +490,12 @@ app.post('/api/sources', requireAuth, async (req, res) => { // Update a Prometheus source app.put('/api/sources/:id', requireAuth, async (req, res) => { - let { name, url, description } = req.body; + let { name, url, description, is_server_source } = req.body; if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url; try { await db.query( - 'UPDATE prometheus_sources SET name = ?, url = ?, description = ? WHERE id = ?', - [name, url, description || '', req.params.id] + 'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ? WHERE id = ?', + [name, url, description || '', is_server_source ? 1 : 0, req.params.id] ); // Clear network history cache await cache.del('network_history_all'); @@ -537,7 +545,11 @@ app.get('/api/settings', async (req, res) => { title: '数据可视化展示大屏', logo_url: null, show_95_bandwidth: 0, - p95_type: 'tx' + p95_type: 'tx', + blackbox_source_id: null, + latency_source: null, + latency_dest: null, + latency_target: null }); } res.json(rows[0]); @@ -549,19 +561,28 @@ app.get('/api/settings', async (req, res) => { // Update site settings app.post('/api/settings', requireAuth, async (req, res) => { - const { page_name, title, logo_url, default_theme, show_95_bandwidth, p95_type } = req.body; + const { page_name, title, logo_url, default_theme, show_95_bandwidth, p95_type, blackbox_source_id, latency_source, latency_dest, latency_target } = req.body; try { await db.query( - `INSERT INTO site_settings (id, page_name, title, logo_url, default_theme, show_95_bandwidth, p95_type) - VALUES (1, ?, ?, ?, ?, ?, ?) + `INSERT INTO site_settings (id, page_name, title, logo_url, default_theme, show_95_bandwidth, p95_type, blackbox_source_id, latency_source, latency_dest, latency_target) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE page_name = VALUES(page_name), title = VALUES(title), logo_url = VALUES(logo_url), default_theme = VALUES(default_theme), show_95_bandwidth = VALUES(show_95_bandwidth), - p95_type = VALUES(p95_type)`, - [page_name, title, logo_url, default_theme, show_95_bandwidth ? 1 : 0, p95_type || 'tx'] + p95_type = VALUES(p95_type), + blackbox_source_id = VALUES(blackbox_source_id), + latency_source = VALUES(latency_source), + latency_dest = VALUES(latency_dest), + latency_target = VALUES(latency_target)`, + [ + page_name, title, logo_url, default_theme, + show_95_bandwidth ? 1 : 0, p95_type || 'tx', + blackbox_source_id || null, latency_source || null, + latency_dest || null, latency_target || null + ] ); res.json({ success: true }); } catch (err) { @@ -574,7 +595,7 @@ app.post('/api/settings', requireAuth, async (req, res) => { // Reusable function to get overview metrics async function getOverview() { - const [sources] = await db.query('SELECT * FROM prometheus_sources'); + const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1'); if (sources.length === 0) { return { totalServers: 0, @@ -715,7 +736,7 @@ app.get('/api/metrics/network-history', async (req, res) => { const cached = await cache.get(cacheKey); if (cached) return res.json(cached); - const [sources] = await db.query('SELECT * FROM prometheus_sources'); + const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1'); if (sources.length === 0) { return res.json({ timestamps: [], rx: [], tx: [] }); } @@ -744,7 +765,7 @@ app.get('/api/metrics/network-history', async (req, res) => { // Get CPU usage history for sparklines app.get('/api/metrics/cpu-history', async (req, res) => { try { - const [sources] = await db.query('SELECT * FROM prometheus_sources'); + const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1'); if (sources.length === 0) { return res.json({ timestamps: [], values: [] }); } @@ -821,6 +842,28 @@ app.get('*', (req, res, next) => { }); +// Get latency for A-B connection +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 }); + } + + // 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 }); + } + + const latency = await prometheusService.getLatency(sources[0].url, settings[0].latency_target); + res.json({ latency }); + } catch (err) { + console.error('Error fetching latency:', err); + res.status(500).json({ error: 'Failed to fetch latency' }); + } +}); + // ==================== WebSocket Server ==================== const server = http.createServer(app); diff --git a/server/prometheus-service.js b/server/prometheus-service.js index 31b2c47..aedcde0 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -800,5 +800,22 @@ module.exports = { mergeCpuHistories, getServerDetails, getServerHistory, - resolveToken + resolveToken, + getLatency: async (blackboxUrl, target) => { + if (!blackboxUrl || !target) return null; + try { + const normalized = blackboxUrl.trim().replace(/\/+$/, ''); + const params = new URLSearchParams({ query: `probe_duration_seconds{instance="${target}"}` }); + const res = await fetch(`${normalized}/api/v1/query?${params.toString()}`); + if (!res.ok) return null; + const data = await res.json(); + if (data.status === 'success' && data.data.result.length > 0) { + return parseFloat(data.data.result[0].value[1]) * 1000; + } + return null; + } catch (err) { + console.error(`[Prometheus] Error fetching latency for ${target}:`, err.message); + return null; + } + } };