From d7ac1bedb4c361bac0ed7a3717cf55de5f1e3b2c Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 11 Apr 2026 18:14:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/app.js | 56 +++++++++++++++++++++++++++++++----- server/index.js | 34 ++++++++++++++++------ server/prometheus-service.js | 15 +++------- 3 files changed, 79 insertions(+), 26 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index a4b4111..ef57932 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -164,6 +164,8 @@ let myMap2D = null; let editingRouteId = null; + let allStoredSources = []; + let allStoredLatencyRoutes = []; async function fetchJsonWithFallback(urls) { let lastError = null; @@ -330,6 +332,31 @@ dom.btnChangePassword.addEventListener('click', saveChangePassword); } + // Settings management event delegation + if (dom.sourceItems) { + dom.sourceItems.addEventListener('click', (e) => { + const btnEdit = e.target.closest('.btn-edit-source'); + const btnDelete = e.target.closest('.btn-delete-source'); + if (btnEdit) { + window.editSource(parseInt(btnEdit.getAttribute('data-id'), 10)); + } else if (btnDelete) { + window.deleteSource(parseInt(btnDelete.getAttribute('data-id'), 10)); + } + }); + } + + if (dom.latencyRoutesList) { + dom.latencyRoutesList.addEventListener('click', (e) => { + const btnEdit = e.target.closest('.btn-edit-route'); + const btnDelete = e.target.closest('.btn-delete-route'); + if (btnEdit) { + window.editRoute(parseInt(btnEdit.getAttribute('data-id'), 10)); + } else if (btnDelete) { + window.deleteLatencyRoute(parseInt(btnDelete.getAttribute('data-id'), 10)); + } + }); + } + // Globe expansion (FLIP animation via Web Animations API) let savedGlobeRect = null; let globeAnimating = false; @@ -1811,8 +1838,8 @@ card.className = 'detail-metric-card'; card.style.flex = '1 1 calc(50% - 10px)'; card.innerHTML = ` - ${item.name} - ${item.value || '-'} + ${escapeHtml(item.name)} + ${escapeHtml(item.value || '-')} `; customDataContainer.appendChild(card); }); @@ -1830,6 +1857,19 @@ diskMetricItem.after(dom.detailPartitionsContainer); } + dom.detailPartitionsList.innerHTML = data.partitions.map(p => ` +
+
+ ${escapeHtml(p.mountpoint || p.device)} + ${escapeHtml(formatBytes(p.used))} / ${escapeHtml(formatBytes(p.total))} +
+
+
+
+ ${p.percent.toFixed(1)}% +
+ `).join(''); + dom.partitionHeader.onclick = (e) => { e.stopPropagation(); dom.detailPartitionsContainer.classList.toggle('active'); @@ -2309,6 +2349,7 @@ return; } const routes = await response.json(); + allStoredLatencyRoutes = routes; renderLatencyRoutes(routes); } catch (err) { console.error('Error loading latency routes:', err); @@ -2322,7 +2363,7 @@ } dom.latencyRoutesList.innerHTML = routes.map(route => ` -
+
${escapeHtml(route.latency_source)} ↔ ${escapeHtml(route.latency_dest)} @@ -2333,8 +2374,8 @@
- - + +
`).join(''); @@ -2555,6 +2596,7 @@ } const sources = await response.json(); const sourcesArray = Array.isArray(sources) ? sources : []; + allStoredSources = sourcesArray; const promSources = sourcesArray.filter(s => s.type !== 'blackbox'); if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`; updateSourceFilterOptions(sourcesArray); @@ -2587,8 +2629,8 @@ ${source.description ? `
${escapeHtml(source.description)}
` : ''}
- - + +
`).join(''); diff --git a/server/index.js b/server/index.js index 3068e24..bb161bc 100644 --- a/server/index.js +++ b/server/index.js @@ -28,6 +28,7 @@ const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 864 const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000; const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true'; const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true'; +const APP_SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex'); const RATE_LIMITS = { login: { windowMs: 15 * 60 * 1000, max: 8 }, setup: { windowMs: 10 * 60 * 1000, max: 20 } @@ -619,6 +620,7 @@ COOKIE_SECURE=${process.env.COOKIE_SECURE || 'false'} SESSION_TTL_SECONDS=${process.env.SESSION_TTL_SECONDS || SESSION_TTL_SECONDS} PASSWORD_ITERATIONS=${process.env.PASSWORD_ITERATIONS || PASSWORD_ITERATIONS} ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'} +APP_SECRET=${process.env.APP_SECRET || APP_SECRET} `; fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent); @@ -1384,6 +1386,8 @@ app.get('/api/metrics/latency', async (req, res) => { const server = http.createServer(app); const wss = new WebSocket.Server({ server }); let isBroadcastRunning = false; +let cachedLatencyRoutes = null; +let lastRoutesUpdate = 0; function broadcast(data) { const message = JSON.stringify(data); @@ -1394,21 +1398,25 @@ function broadcast(data) { }); } -// Broadcast loop async function broadcastMetrics() { if (isBroadcastRunning) return; isBroadcastRunning = true; try { const overview = await getOverview(); - // Also include latencies in the broadcast to make map lines real-time - const [routes] = await db.query(` - SELECT r.*, s.url, s.type as source_type - FROM latency_routes r - JOIN prometheus_sources s ON r.source_id = s.id - `); + // Refresh routes list every 60 seconds or if it hasn't been fetched yet + const now = Date.now(); + if (!cachedLatencyRoutes || now - lastRoutesUpdate > 60000) { + const [routes] = await db.query(` + SELECT r.*, s.url, s.type as source_type + FROM latency_routes r + JOIN prometheus_sources s ON r.source_id = s.id + `); + cachedLatencyRoutes = routes; + lastRoutesUpdate = now; + } - const latencyResults = await Promise.all(routes.map(async (route) => { + const latencyResults = await Promise.all(cachedLatencyRoutes.map(async (route) => { let latency = await cache.get(`latency:route:${route.id}`); if (latency === null && route.source_type === 'prometheus') { latency = await prometheusService.getLatency(route.url, route.latency_target); @@ -1478,6 +1486,16 @@ async function start() { } }, 3600000); // Once per hour + // Periodic cleanup of sessions Map to prevent memory growth + setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of sessions.entries()) { + if (session.expiresAt && session.expiresAt <= now) { + sessions.delete(sessionId); + } + } + }, 300000); // Once every 5 minutes + server.listen(PORT, HOST, () => { console.log(`\n 🚀 Data Visualization Display Wall (WebSocket Enabled)`); console.log(` 📊 Server running at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`); diff --git a/server/prometheus-service.js b/server/prometheus-service.js index 0d263fd..6c1b117 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -876,10 +876,8 @@ module.exports = { getLatency: async (blackboxUrl, target) => { if (!blackboxUrl || !target) return null; try { - const normalized = blackboxUrl.trim().replace(/\/+$/, ''); + const normalized = normalizeUrl(blackboxUrl); - // Construct a single optimized query searching for priority metrics and common labels - // Prioritize probe_icmp_duration_seconds OVER probe_duration_seconds const queryExpr = `( probe_icmp_duration_seconds{phase="rtt", instance="${target}"} or probe_icmp_duration_seconds{phase="rtt", target="${target}"} or @@ -891,14 +889,9 @@ module.exports = { probe_duration_seconds{target="${target}"} )`; - const params = new URLSearchParams({ query: queryExpr }); - const res = await fetch(`${normalized}/api/v1/query?${params.toString()}`); - - if (res.ok) { - const data = await res.json(); - if (data.status === 'success' && data.data.result.length > 0) { - return parseFloat(data.data.result[0].value[1]) * 1000; - } + const result = await query(normalized, queryExpr); + if (result && result.length > 0) { + return parseFloat(result[0].value[1]) * 1000; } return null; } catch (err) {