From 8e50437ed4aee4b71ebcaeafad902d31b76b5754 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 24 Apr 2026 15:46:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E7=AB=AF=E7=9A=84?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 5 ++ public/js/app.js | 7 +- server/db-schema-check.js | 8 +- server/index.js | 157 +++++++++++++++++++++++------------ server/prometheus-service.js | 12 ++- 5 files changed, 126 insertions(+), 63 deletions(-) diff --git a/public/index.html b/public/index.html index ad7fb33..a1741fc 100644 --- a/public/index.html +++ b/public/index.html @@ -588,6 +588,11 @@ +
+ + +

后端将按此频率主动从 Prometheus 抓取数据并缓存。设为 0 则禁用自动同步。建议值:15-60s。

+
diff --git a/public/js/app.js b/public/js/app.js index f97bf7e..6b7fcde 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -132,7 +132,8 @@ btnAddCustomMetric: document.getElementById('btnAddCustomMetric'), btnSaveCustomMetrics: document.getElementById('btnSaveCustomMetrics'), customDataContainer: document.getElementById('customDataContainer'), - cdnUrlInput: document.getElementById('cdnUrlInput') + cdnUrlInput: document.getElementById('cdnUrlInput'), + prometheusCacheTtlInput: document.getElementById('prometheusCacheTtlInput') }; // ---- State ---- @@ -655,6 +656,7 @@ if (dom.logoUrlDarkInput) dom.logoUrlDarkInput.value = window.SITE_SETTINGS.logo_url_dark || ''; if (dom.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || ''; if (dom.showServerIpInput) dom.showServerIpInput.value = window.SITE_SETTINGS.show_server_ip ? "1" : "0"; + if (dom.prometheusCacheTtlInput) dom.prometheusCacheTtlInput.value = window.SITE_SETTINGS.prometheus_cache_ttl !== undefined ? window.SITE_SETTINGS.prometheus_cache_ttl : 30; // Apply security dependency updateSecurityDependency(); @@ -2311,7 +2313,8 @@ ip_metric_name: dom.ipMetricNameInput ? dom.ipMetricNameInput.value.trim() : null, ip_label_name: dom.ipLabelNameInput ? dom.ipLabelNameInput.value.trim() : 'address', custom_metrics: getCustomMetricsFromUI(), - cdn_url: dom.cdnUrlInput ? dom.cdnUrlInput.value.trim() : '' + cdn_url: dom.cdnUrlInput ? dom.cdnUrlInput.value.trim() : '', + prometheus_cache_ttl: dom.prometheusCacheTtlInput ? parseInt(dom.prometheusCacheTtlInput.value) : 30 }; try { diff --git a/server/db-schema-check.js b/server/db-schema-check.js index 2446f7f..ad92c15 100644 --- a/server/db-schema-check.js +++ b/server/db-schema-check.js @@ -74,14 +74,15 @@ const SCHEMA = { ip_label_name VARCHAR(100) DEFAULT 'address', custom_metrics JSON DEFAULT NULL, cdn_url VARCHAR(500) DEFAULT NULL, + prometheus_cache_ttl INT DEFAULT 30, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `, seedSql: ` INSERT IGNORE INTO site_settings ( - id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details + id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details, prometheus_cache_ttl ) VALUES ( - 1, '数据可视化展示大屏', 1, '数据可视化展示大屏', 'dark', 0, 'tx', 1 + 1, '数据可视化展示大屏', 1, '数据可视化展示大屏', 'dark', 0, 'tx', 1, 30 ) `, columns: [ @@ -105,7 +106,8 @@ const SCHEMA = { { name: 'ip_metric_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_metric_name VARCHAR(100) DEFAULT NULL AFTER show_server_ip" }, { name: 'ip_label_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_label_name VARCHAR(100) DEFAULT 'address' AFTER ip_metric_name" }, { name: 'custom_metrics', sql: "ALTER TABLE site_settings ADD COLUMN custom_metrics JSON DEFAULT NULL AFTER ip_label_name" }, - { name: 'cdn_url', sql: "ALTER TABLE site_settings ADD COLUMN cdn_url VARCHAR(500) DEFAULT NULL AFTER custom_metrics" } + { name: 'cdn_url', sql: "ALTER TABLE site_settings ADD COLUMN cdn_url VARCHAR(500) DEFAULT NULL AFTER custom_metrics" }, + { name: 'prometheus_cache_ttl', sql: "ALTER TABLE site_settings ADD COLUMN prometheus_cache_ttl INT DEFAULT 30 AFTER cdn_url" } ] }, traffic_stats: { diff --git a/server/index.js b/server/index.js index e71c194..cad1c48 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ const fs = require('fs'); const crypto = require('crypto'); let isDbInitialized = false; +let metricSyncTimer = null; // Background sync timer const sessions = new Map(); // Fallback session store when Valkey is unavailable const requestBuckets = new Map(); const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400; @@ -152,7 +153,8 @@ function getPublicSiteSettings(settings = {}) { ip_metric_name: settings.ip_metric_name || null, ip_label_name: settings.ip_label_name || 'address', custom_metrics: settings.custom_metrics || [], - cdn_url: settings.cdn_url || null + cdn_url: settings.cdn_url || null, + prometheus_cache_ttl: settings.prometheus_cache_ttl !== undefined ? parseInt(settings.prometheus_cache_ttl) : 30 }; } @@ -342,7 +344,82 @@ async function checkDb() { } } -checkDb(); +checkDb().then(() => { + if (isDbInitialized) { + startMetricSync(); + } +}); + +/** + * Background Metric Synchronization Task + */ +async function startMetricSync() { + if (metricSyncTimer) { + clearInterval(metricSyncTimer); + metricSyncTimer = null; + } + + try { + const [rows] = await db.query('SELECT prometheus_cache_ttl FROM site_settings WHERE id = 1'); + const ttl = rows.length > 0 ? (parseInt(rows[0].prometheus_cache_ttl) || 0) : 30; + + if (ttl <= 0) { + console.log('[MetricSync] Disabled (TTL=0)'); + return; + } + + console.log(`[MetricSync] Started with interval: ${ttl}s`); + + // Immediate first run + runSync(); + + metricSyncTimer = setInterval(runSync, ttl * 1000); + } catch (err) { + console.error('[MetricSync] Failed to start:', err); + } +} + +async function runSync() { + try { + const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE type != "blackbox"'); + if (sources.length === 0) return; + + console.log(`[MetricSync] Syncing ${sources.length} sources at ${new Date().toLocaleTimeString()}`); + + // 1. Sync individual source metrics (Overview & Detail) + await Promise.all(sources.map(async (source) => { + try { + const metrics = await prometheusService.getOverviewMetrics(source.url, source.name); + const enrichedMetrics = { + ...metrics, + sourceName: source.name, + isOverview: !!source.is_overview_source, + isDetail: !!source.is_detail_source + }; + const cacheKey = `source_metrics:${source.url}:${source.name}`; + await cache.set(cacheKey, enrichedMetrics, 86400); // Store for 24h, will be overwritten + } catch (err) { + console.error(`[MetricSync] Error syncing ${source.name}:`, err.message); + } + })); + + // 2. Sync Network History (Optional but good for "real-time" feel) + const [historySources] = await db.query('SELECT * FROM prometheus_sources WHERE is_overview_source = 1 AND type != "blackbox"'); + if (historySources.length > 0) { + const histories = await Promise.all(historySources.map(source => + prometheusService.getNetworkHistory(source.url).catch(() => null) + )); + const validHistories = histories.filter(h => h !== null); + if (validHistories.length > 0) { + const merged = prometheusService.mergeNetworkHistories(validHistories); + await cache.set('network_history_all', merged, 86400); + } + } + } catch (err) { + console.error('[MetricSync] Sync loop error:', err); + } +} + // --- Health API --- app.get('/health', async (req, res) => { @@ -953,7 +1030,7 @@ app.post('/api/settings', requireAuth, async (req, res) => { page_name, show_page_name, title, logo_url, logo_url_dark, favicon_url, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details, icp_filing, ps_filing, show_server_ip, ip_metric_name, ip_label_name, custom_metrics, - cdn_url + cdn_url, prometheus_cache_ttl } = req.body; // 3. Prepare parameters, prioritizing body but falling back to current @@ -980,7 +1057,10 @@ app.post('/api/settings', requireAuth, async (req, res) => { ip_metric_name: ip_metric_name !== undefined ? ip_metric_name : (current.ip_metric_name || null), ip_label_name: ip_label_name !== undefined ? ip_label_name : (current.ip_label_name || 'address'), custom_metrics: custom_metrics !== undefined ? JSON.stringify(custom_metrics) : (current.custom_metrics || '[]'), - cdn_url: cdn_url !== undefined ? cdn_url : (current.cdn_url || null) + cdn_url: cdn_url !== undefined ? cdn_url : (current.cdn_url || null), + prometheus_cache_ttl: prometheus_cache_ttl !== undefined + ? Math.min(86400, Math.max(0, parseInt(prometheus_cache_ttl) || 0)) + : (current.prometheus_cache_ttl !== undefined ? current.prometheus_cache_ttl : 30) }; await db.query(` @@ -989,8 +1069,8 @@ app.post('/api/settings', requireAuth, async (req, res) => { default_theme, show_95_bandwidth, p95_type, require_login_for_server_details, blackbox_source_id, latency_source, latency_dest, latency_target, icp_filing, ps_filing, show_server_ip, ip_metric_name, ip_label_name, - custom_metrics, cdn_url - ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + custom_metrics, cdn_url, prometheus_cache_ttl + ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE page_name = VALUES(page_name), show_page_name = VALUES(show_page_name), @@ -1012,17 +1092,19 @@ app.post('/api/settings', requireAuth, async (req, res) => { ip_metric_name = VALUES(ip_metric_name), ip_label_name = VALUES(ip_label_name), custom_metrics = VALUES(custom_metrics), - cdn_url = VALUES(cdn_url)`, + cdn_url = VALUES(cdn_url), + prometheus_cache_ttl = VALUES(prometheus_cache_ttl)`, [ settings.page_name, settings.show_page_name, settings.title, settings.logo_url, settings.logo_url_dark, settings.favicon_url, settings.default_theme, settings.show_95_bandwidth, settings.p95_type, settings.require_login_for_server_details, settings.blackbox_source_id, settings.latency_source, settings.latency_dest, settings.latency_target, settings.icp_filing, settings.ps_filing, settings.show_server_ip, settings.ip_metric_name, settings.ip_label_name, settings.custom_metrics, - settings.cdn_url + settings.cdn_url, settings.prometheus_cache_ttl ] ); + startMetricSync(); res.json({ success: true, settings: getPublicSiteSettings(settings) }); } catch (err) { console.error('Error updating settings:', err); @@ -1049,30 +1131,17 @@ async function getOverview(force = false) { servers: [] }; } + + // If force is true, trigger an immediate sync run + if (force) { + await runSync(); + } + + // ONLY read from cache. const allMetrics = await Promise.all(sources.map(async (source) => { const cacheKey = `source_metrics:${source.url}:${source.name}`; - if (force) { - await cache.del(cacheKey); - } else { - const cached = await cache.get(cacheKey); - if (cached) return cached; - } - - try { - const metrics = await prometheusService.getOverviewMetrics(source.url, source.name); - const enrichedMetrics = { - ...metrics, - sourceName: source.name, - isOverview: !!source.is_overview_source, - isDetail: !!source.is_detail_source - }; - - await cache.set(cacheKey, enrichedMetrics, 15); // Cache for 15s - return enrichedMetrics; - } catch (err) { - console.error(`Error fetching metrics from ${source.name}:`, err.message); - return null; - } + const cached = await cache.get(cacheKey); + return cached || null; })); const validMetrics = allMetrics.filter(m => m !== null); @@ -1234,31 +1303,11 @@ app.get('/api/metrics/network-history', async (req, res) => { if (force) { await cache.del(cacheKey); } else { - const cached = await cache.get(cacheKey); - if (cached) return res.json(cached); - } + const cached = await cache.get(cacheKey); + if (cached) return res.json(cached); - const query = 'SELECT * FROM prometheus_sources WHERE is_overview_source = 1 AND type != "blackbox"'; - const [sources] = await db.query(query); - if (sources.length === 0) { - return res.json({ timestamps: [], rx: [], tx: [] }); - } - - const histories = await Promise.all(sources.map(source => - prometheusService.getNetworkHistory(source.url).catch(err => { - console.error(`Error fetching network history from ${source.name}:`, err.message); - return null; - }) - )); - - const validHistories = histories.filter(h => h !== null); - if (validHistories.length === 0) { - return res.json({ timestamps: [], rx: [], tx: [] }); - } - - const merged = prometheusService.mergeNetworkHistories(validHistories); - await cache.set(cacheKey, merged, 300); // Cache for 5 minutes - res.json(merged); + // Fallback: If no cache, return empty instead of triggering Prometheus + return res.json({ timestamps: [], rx: [], tx: [] }); } catch (err) { console.error('Error fetching network history history:', err); res.status(500).json({ error: 'Failed to fetch network history history' }); diff --git a/server/prometheus-service.js b/server/prometheus-service.js index 4c721e6..7495edf 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -1,12 +1,15 @@ const axios = require('axios'); const http = require('http'); const https = require('https'); -const cache = require('./cache'); // <-- ADD +const cache = require('./cache'); +const crypto = require('crypto'); const QUERY_TIMEOUT = 10000; -// Reusable agents to handle potential redirect issues and protocol mismatches -const crypto = require('crypto'); +function getCacheKey(type, baseUrl, expr, extra = '') { + return `prom_${type}:${crypto.createHash('md5').update(`${baseUrl}:${expr}:${extra}`).digest('hex')}`; +} + const httpAgent = new http.Agent({ keepAlive: true }); const httpsAgent = new https.Agent({ keepAlive: true }); @@ -951,5 +954,6 @@ module.exports = { console.error(`[Prometheus] Error fetching latency for ${target}:`, err.message); return null; } - } + }, + getCacheKey };