From 7362bcf20679a14bd0d28120e088f536b6dcd352 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 10 Apr 2026 23:42:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E9=94=99=E8=AF=AF=20=E6=96=B0=E5=A2=9E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 47 +++++---- public/js/app.js | 106 +++++++++++++++++-- server/db-integrity-check.js | 197 ----------------------------------- server/db-schema-check.js | 5 + server/index.js | 29 ++++-- server/init-db.js | 108 ++++--------------- server/prometheus-service.js | 151 ++++++++++++++++----------- 7 files changed, 266 insertions(+), 377 deletions(-) delete mode 100644 server/db-integrity-check.js diff --git a/public/index.html b/public/index.html index 95c7dca..943d0c8 100644 --- a/public/index.html +++ b/public/index.html @@ -587,7 +587,7 @@

开启后,未登录访客仍可看到大屏总览,但点击单台服务器时需要先登录。

- +

开启后,点击服务器详情时会显示该服务器的公网 IP 地址。

- -
-

高级指标配置 (可选)

- -
- - -

留空则使用 Prometheus Target 自动发现。若需兼容 node_exporter 自定义指标(如 textfile),请在此输入指标名。

-
- -
- - -

从该指标的哪个标签提取 IP?默认为 address

-
+
+ + +

如果您的 Prometheus 中有专门记录 IP 的指标,请在此输入。留空则尝试自动发现。

+
+
+ +
@@ -620,6 +611,26 @@
+ +
+
+
+

服务器详情指标配置

+ +
+ +
+ +
+ +
+ +
+
+
+
diff --git a/public/js/app.js b/public/js/app.js index a767168..5b8be6c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -121,9 +121,13 @@ icpFilingInput: document.getElementById('icpFilingInput'), psFilingInput: document.getElementById('psFilingInput'), icpFilingDisplay: document.getElementById('icpFilingDisplay'), - psFilingDisplay: document.getElementById('psFilingDisplay'), - psFilingText: document.getElementById('psFilingText'), - copyrightYear: document.getElementById('copyrightYear') + ps_filingDisplay: document.getElementById('psFilingDisplay'), + ps_filingText: document.getElementById('psFilingText'), + copyrightYear: document.getElementById('copyrightYear'), + customMetricsList: document.getElementById('customMetricsList'), + btnAddCustomMetric: document.getElementById('btnAddCustomMetric'), + btnSaveCustomMetrics: document.getElementById('btnSaveCustomMetrics'), + customDataContainer: document.getElementById('customDataContainer') }; // ---- State ---- @@ -193,6 +197,71 @@ // Network chart networkChart = new AreaChart(dom.networkCanvas); + // ---- Custom Metrics Helpers ---- + + function addMetricRow(config = {}) { + const row = document.createElement('div'); + row.className = 'metric-row'; + row.style = 'background: rgba(255,255,255,0.03); padding: 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border-color);'; + row.innerHTML = ` +
+ + + +
+
+ + +
+ `; + + row.querySelector('.btn-remove-metric').onclick = () => row.remove(); + dom.customMetricsList.appendChild(row); + } + + function loadCustomMetricsUI(metrics) { + if (!dom.customMetricsList) return; + dom.customMetricsList.innerHTML = ''; + let list = []; + try { + list = typeof metrics === 'string' ? JSON.parse(metrics) : (metrics || []); + } catch(e) {} + + if (Array.isArray(list)) { + list.forEach(m => addMetricRow(m)); + } + + if (list.length === 0) { + // Add a placeholder/default row if empty + } + } + + function getCustomMetricsFromUI() { + const rows = dom.customMetricsList.querySelectorAll('.metric-row'); + const metrics = []; + rows.forEach(row => { + const name = row.querySelector('.metric-display-name').value.trim(); + const metric = row.querySelector('.metric-query').value.trim(); + const label = row.querySelector('.metric-label').value.trim(); + const is_ip = row.querySelector('.metric-is-ip').checked; + if (metric) { + metrics.push({ name, metric, label, is_ip }); + } + }); + return metrics; + } + + // Bind Events + if (dom.btnAddCustomMetric) dom.btnAddCustomMetric.onclick = () => addMetricRow(); + if (dom.btnSaveCustomMetrics) { + dom.btnSaveCustomMetrics.onclick = saveSiteSettings; + } +})(); + // Initial map initMap2D(); @@ -547,6 +616,9 @@ // Apply security dependency updateSecurityDependency(); + + // Load custom metrics + loadCustomMetricsUI(window.SITE_SETTINGS.custom_metrics); } loadSiteSettings(); @@ -1715,7 +1787,25 @@
`).join(''); - // Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric + // Render Custom Data + const customDataContainer = dom.customDataContainer; + if (customDataContainer) { + customDataContainer.innerHTML = ''; + if (data.custom_data && data.custom_data.length > 0) { + data.custom_data.forEach(item => { + const card = document.createElement('div'); + card.className = 'detail-metric-card'; + card.style.flex = '1 1 calc(50% - 10px)'; + card.innerHTML = ` + ${item.name} + ${item.value || '-'} + `; + customDataContainer.appendChild(card); + }); + } + } + + // Partitions if (data.partitions && data.partitions.length > 0) { dom.detailPartitionsContainer.style.display = 'block'; dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`; @@ -1997,8 +2087,11 @@ // Update IP visibility input if (dom.showServerIpInput) dom.showServerIpInput.value = settings.show_server_ip ? "1" : "0"; if (dom.ipMetricNameInput) dom.ipMetricNameInput.value = settings.ip_metric_name || ''; - if (dom.ipLabelNameInput) dom.ipLabelNameInput.value = settings.ip_label_name || 'address'; + if (dom.ipLabelNameInput) dom.ipLabelNameInput.value = settings.ip_label_name || ''; + // Load Custom Metrics + loadCustomMetricsUI(settings.custom_metrics); + // Sync security tab dependency updateSecurityDependency(); } catch (err) { @@ -2118,7 +2211,8 @@ network_data_sources: Array.from(dom.networkSourceSelector.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).join(','), show_server_ip: dom.showServerIpInput ? (dom.showServerIpInput.value === "1") : false, ip_metric_name: dom.ipMetricNameInput ? dom.ipMetricNameInput.value.trim() : null, - ip_label_name: dom.ipLabelNameInput ? dom.ipLabelNameInput.value.trim() : 'address' + ip_label_name: dom.ipLabelNameInput ? dom.ipLabelNameInput.value.trim() : 'address', + custom_metrics: getCustomMetricsFromUI() }; // UI Feedback for both potential save buttons diff --git a/server/db-integrity-check.js b/server/db-integrity-check.js deleted file mode 100644 index 8006f17..0000000 --- a/server/db-integrity-check.js +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Database Integrity Check - * Runs at startup to ensure all required tables exist. - * Recreates the database if any tables are missing. - */ -require('dotenv').config(); -const mysql = require('mysql2/promise'); -const db = require('./db'); -const path = require('path'); -const fs = require('fs'); - -const REQUIRED_TABLES = [ - 'users', - 'prometheus_sources', - 'site_settings', - 'traffic_stats', - 'server_locations', - 'latency_routes' -]; - -async function checkAndFixDatabase() { - const envPath = path.join(__dirname, '..', '.env'); - if (!fs.existsSync(envPath)) return; - - try { - // Check tables - const [rows] = await db.query("SHOW TABLES"); - const existingTables = rows.map(r => Object.values(r)[0]); - - const missingTables = REQUIRED_TABLES.filter(t => !existingTables.includes(t)); - - if (missingTables.length > 0) { - console.log(`[Database Integrity] ⚠️ Missing tables: ${missingTables.join(', ')}. Creating them...`); - - for (const table of missingTables) { - await createTable(table); - } - console.log(`[Database Integrity] ✅ Missing tables created.`); - } - - // Check for is_server_source and type 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.`); - } - - if (!promColumnNames.includes('type')) { - console.log(`[Database Integrity] ⚠️ Missing column 'type' in 'prometheus_sources'. Adding it...`); - await db.query("ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source"); - console.log(`[Database Integrity] ✅ Column 'type' 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); - const addColumn = async (columnName, sql) => { - if (!columnNames.includes(columnName)) { - try { - console.log(`[Database Integrity] ⚠️ Missing column '${columnName}' in 'site_settings'. Adding it...`); - await db.query(sql); - console.log(`[Database Integrity] ✅ Column '${columnName}' added.`); - } catch (err) { - console.error(`[Database Integrity] ❌ Failed to add column '${columnName}':`, err.message); - // Try without AFTER if it exists - if (sql.includes('AFTER')) { - try { - const fallback = sql.split(' AFTER')[0]; - console.log(`[Database Integrity] 🔄 Retrying column '${columnName}' WITHOUT 'AFTER'...`); - await db.query(fallback); - console.log(`[Database Integrity] ✅ Column '${columnName}' added via fallback.`); - } catch (err2) { - console.error(`[Database Integrity] ❌ Fallback also failed:`, err2.message); - } - } - } - } - }; - - await addColumn('show_page_name', "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name"); - await addColumn('show_95_bandwidth', "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme"); - await addColumn('p95_type', "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"); - await addColumn('require_login_for_server_details', "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type"); - await addColumn('blackbox_source_id', "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type"); - await addColumn('latency_source', "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id"); - await addColumn('latency_dest', "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source"); - await addColumn('latency_target', "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest"); - await addColumn('icp_filing', "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target"); - await addColumn('ps_filing', "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing"); - await addColumn('logo_url_dark', "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url"); - await addColumn('favicon_url', "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark"); - } catch (err) { - console.error('[Database Integrity] ❌ Overall site_settings check error:', err.message); - } -} - -async function createTable(tableName) { - console.log(` - Creating table "${tableName}"...`); - switch (tableName) { - case 'users': - await db.query(` - CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(255) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - salt VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - break; - case 'prometheus_sources': - await db.query(` - CREATE TABLE IF NOT EXISTS prometheus_sources ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - url VARCHAR(500) NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - break; - case 'site_settings': - await db.query(` - CREATE TABLE IF NOT EXISTS site_settings ( - id INT PRIMARY KEY DEFAULT 1, - page_name VARCHAR(255) DEFAULT '数据可视化展示大屏', - show_page_name TINYINT(1) DEFAULT 1, - title VARCHAR(255) DEFAULT '数据可视化展示大屏', - logo_url TEXT, - logo_url_dark TEXT, - favicon_url TEXT, - default_theme VARCHAR(20) DEFAULT 'dark', - show_95_bandwidth TINYINT(1) DEFAULT 0, - p95_type VARCHAR(20) DEFAULT 'tx', - require_login_for_server_details TINYINT(1) DEFAULT 1, - blackbox_source_id INT, - latency_source VARCHAR(100), - latency_dest VARCHAR(100), - latency_target VARCHAR(255), - icp_filing VARCHAR(255), - ps_filing VARCHAR(255), - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - await db.query(` - INSERT IGNORE INTO site_settings (id, page_name, title, default_theme, show_95_bandwidth) - VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0) - `); - break; - case 'traffic_stats': - await db.query(` - CREATE TABLE IF NOT EXISTS traffic_stats ( - id INT AUTO_INCREMENT PRIMARY KEY, - rx_bytes BIGINT UNSIGNED DEFAULT 0, - tx_bytes BIGINT UNSIGNED DEFAULT 0, - rx_bandwidth DOUBLE DEFAULT 0, - tx_bandwidth DOUBLE DEFAULT 0, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE INDEX (timestamp) - ) 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 ( - id INT AUTO_INCREMENT PRIMARY KEY, - ip VARCHAR(255) NOT NULL UNIQUE, - country CHAR(2), - country_name VARCHAR(100), - region VARCHAR(100), - city VARCHAR(100), - latitude DOUBLE, - longitude DOUBLE, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - break; - } -} - -module.exports = checkAndFixDatabase; diff --git a/server/db-schema-check.js b/server/db-schema-check.js index ec2909b..0b76471 100644 --- a/server/db-schema-check.js +++ b/server/db-schema-check.js @@ -68,6 +68,7 @@ const SCHEMA = { show_server_ip TINYINT(1) DEFAULT 0, ip_metric_name VARCHAR(100) DEFAULT NULL, ip_label_name VARCHAR(100) DEFAULT 'address', + custom_metrics JSON DEFAULT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `, @@ -142,6 +143,10 @@ const SCHEMA = { { 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" } ] }, diff --git a/server/index.js b/server/index.js index 711218d..6279052 100644 --- a/server/index.js +++ b/server/index.js @@ -148,7 +148,8 @@ function getPublicSiteSettings(settings = {}) { network_data_sources: settings.network_data_sources || null, show_server_ip: settings.show_server_ip ? 1 : 0, ip_metric_name: settings.ip_metric_name || null, - ip_label_name: settings.ip_label_name || 'address' + ip_label_name: settings.ip_label_name || 'address', + custom_metrics: settings.custom_metrics || [] }; } @@ -905,7 +906,7 @@ app.post('/api/settings', requireAuth, async (req, res) => { const { 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, network_data_sources, show_server_ip + icp_filing, ps_filing, network_data_sources, show_server_ip, ip_metric_name, ip_label_name, custom_metrics } = req.body; // 3. Prepare parameters, prioritizing body but falling back to current @@ -931,7 +932,8 @@ app.post('/api/settings', requireAuth, async (req, res) => { network_data_sources: network_data_sources !== undefined ? network_data_sources : (current.network_data_sources || null), show_server_ip: show_server_ip !== undefined ? (show_server_ip ? 1 : 0) : (current.show_server_ip || 0), 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') + 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 || '[]') }; await db.query(` @@ -939,8 +941,8 @@ app.post('/api/settings', requireAuth, async (req, res) => { id, 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, blackbox_source_id, latency_source, latency_dest, latency_target, - icp_filing, ps_filing, network_data_sources, show_server_ip - ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + icp_filing, ps_filing, network_data_sources, show_server_ip, ip_metric_name, ip_label_name + ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE page_name = VALUES(page_name), show_page_name = VALUES(show_page_name), @@ -959,12 +961,15 @@ app.post('/api/settings', requireAuth, async (req, res) => { icp_filing = VALUES(icp_filing), ps_filing = VALUES(ps_filing), network_data_sources = VALUES(network_data_sources), - show_server_ip = VALUES(show_server_ip)`, + show_server_ip = VALUES(show_server_ip), + ip_metric_name = VALUES(ip_metric_name), + ip_label_name = VALUES(ip_label_name)`, [ 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.network_data_sources, settings.show_server_ip + settings.icp_filing, settings.ps_filing, settings.network_data_sources, settings.show_server_ip, + settings.ip_metric_name, settings.ip_label_name ] ); @@ -1421,6 +1426,16 @@ async function start() { const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000; setInterval(broadcastMetrics, REFRESH_INT); + // Periodic cleanup of rate limit buckets to prevent memory leak + setInterval(() => { + const now = Date.now(); + for (const [key, bucket] of requestBuckets.entries()) { + if (bucket.resetAt <= now) { + requestBuckets.delete(key); + } + } + }, 3600000); // Once per hour + 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/init-db.js b/server/init-db.js index eeeb410..16ff346 100644 --- a/server/init-db.js +++ b/server/init-db.js @@ -1,102 +1,38 @@ -/** - * Database Initialization Script - * Run: npm run init-db - * Creates the required MySQL database and tables. - */ -require('dotenv').config(); const mysql = require('mysql2/promise'); +const checkAndFixDatabase = require('./db-schema-check'); +const db = require('./db'); async function initDatabase() { - const connection = await mysql.createConnection({ - host: process.env.MYSQL_HOST || 'localhost', - port: parseInt(process.env.MYSQL_PORT) || 3306, - user: process.env.MYSQL_USER || 'root', - password: process.env.MYSQL_PASSWORD || '' - }); - + const host = process.env.MYSQL_HOST || 'localhost'; + const port = parseInt(process.env.MYSQL_PORT) || 3306; + const user = process.env.MYSQL_USER || 'root'; + const password = process.env.MYSQL_PASSWORD || ''; const dbName = process.env.MYSQL_DATABASE || 'display_wall'; - console.log('🔧 Initializing database...\n'); + // 1. Create connection without database selected to create the DB itself + const connection = await mysql.createConnection({ + host, + port, + user, + password + }); + + console.log('🔧 Initializing database environment...\n'); // Create database await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); console.log(` ✅ Database "${dbName}" ready`); + await connection.end(); - await connection.query(`USE \`${dbName}\``); + // 2. Re-initialize the standard pool so it can see the new DB + db.initPool(); - // Create users table - await connection.query(` - CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(255) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - salt VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - console.log(' ✅ Table "users" ready'); - - // Create prometheus_sources table - await connection.query(` - CREATE TABLE IF NOT EXISTS prometheus_sources ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - url VARCHAR(500) NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - console.log(' ✅ Table "prometheus_sources" ready'); - - // Create site_settings table - await connection.query(` - CREATE TABLE IF NOT EXISTS site_settings ( - id INT PRIMARY KEY DEFAULT 1, - page_name VARCHAR(255) DEFAULT '数据可视化展示大屏', - title VARCHAR(255) DEFAULT '数据可视化展示大屏', - logo_url TEXT, - default_theme VARCHAR(20) DEFAULT 'dark', - show_page_name TINYINT(1) DEFAULT 1, - logo_url_dark TEXT, - favicon_url TEXT, - show_95_bandwidth TINYINT(1) DEFAULT 0, - p95_type VARCHAR(20) DEFAULT 'tx', - require_login_for_server_details TINYINT(1) DEFAULT 1, - blackbox_source_id INT, - latency_source VARCHAR(100), - latency_dest VARCHAR(100), - latency_target VARCHAR(255), - icp_filing VARCHAR(255), - ps_filing VARCHAR(255), - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - // Insert default settings if not exists - await connection.query(` - INSERT IGNORE INTO site_settings (id, page_name, title, default_theme) - VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark') - `); - console.log(' ✅ Table "site_settings" ready'); - - // Create server_locations table - await connection.query(` - CREATE TABLE IF NOT EXISTS server_locations ( - id INT AUTO_INCREMENT PRIMARY KEY, - ip VARCHAR(255) NOT NULL UNIQUE, - country CHAR(2), - country_name VARCHAR(100), - region VARCHAR(100), - city VARCHAR(100), - latitude DOUBLE, - longitude DOUBLE, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - console.log(' ✅ Table "server_locations" ready'); + // 3. Use the centralized schema tool to create/fix all tables + console.log(' 📦 Initializing tables using schema-check tool...'); + await checkAndFixDatabase(); + console.log(' ✅ Tables and columns ready'); console.log('\n🎉 Database initialization complete!\n'); - await connection.end(); } initDatabase().catch(err => { diff --git a/server/prometheus-service.js b/server/prometheus-service.js index 9f9d68f..0d263fd 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -9,14 +9,30 @@ const crypto = require('crypto'); const httpAgent = new http.Agent({ keepAlive: true }); const httpsAgent = new https.Agent({ keepAlive: true }); -const serverIdMap = new Map(); // token -> { instance, job, source } +const serverIdMap = new Map(); // token -> { instance, job, source, lastSeen } const SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex'); +// Periodic cleanup of serverIdMap to prevent infinite growth +setInterval(() => { + const now = Date.now(); + const TTL = 24 * 60 * 60 * 1000; // 24 hours + for (const [token, data] of serverIdMap.entries()) { + if (now - (data.lastSeen || 0) > TTL) { + serverIdMap.delete(token); + } + } +}, 3600000); // Once per hour + function getServerToken(instance, job, source) { const hash = crypto.createHmac('sha256', SECRET) .update(`${instance}:${job}:${source}`) .digest('hex') .substring(0, 16); + + // Update lastSeen timestamp + const data = serverIdMap.get(hash); + if (data) data.lastSeen = Date.now(); + return hash; } @@ -222,7 +238,7 @@ async function getOverviewMetrics(url, sourceName) { const token = getServerToken(originalInstance, job, sourceName); // Store mapping for detail queries - serverIdMap.set(token, { instance: originalInstance, source: sourceName, job }); + serverIdMap.set(token, { instance: originalInstance, source: sourceName, job, lastSeen: Date.now() }); if (!instances.has(token)) { instances.set(token, { @@ -582,76 +598,85 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) { await Promise.all(queryPromises); - // Add IP information + // Process custom metrics from settings + results.custom_data = []; try { - let foundIp = false; + const customMetrics = typeof settings.custom_metrics === 'string' + ? JSON.parse(settings.custom_metrics) + : (settings.custom_metrics || []); - // 1. Try Custom Node Exporter Metric if configured - if (settings.ip_metric_name) { - try { - const expr = `${settings.ip_metric_name}{instance="${node}",job="${job}"}`; - const res = await query(url, expr); - if (res && res.length > 0) { - const address = res[0].metric[settings.ip_label_name || 'address']; - if (address) { - if (address.includes(':')) { - results.ipv6 = [address]; - results.ipv4 = []; - } else { - results.ipv4 = [address]; - results.ipv6 = []; + if (Array.isArray(customMetrics) && customMetrics.length > 0) { + const customPromises = customMetrics.map(async (cfg) => { + if (!cfg.metric) return null; + try { + const expr = `${cfg.metric}{instance="${node}",job="${job}"}`; + const res = await query(url, expr); + if (res && res.length > 0) { + const val = res[0].metric[cfg.label || 'address'] || res[0].value[1]; + + // If this metric is marked as an IP source, update the main IP fields + if (cfg.is_ip && !results.ipv4?.length && !results.ipv6?.length) { + if (val.includes(':')) { + results.ipv6 = [val]; + results.ipv4 = []; + } else { + results.ipv4 = [val]; + results.ipv6 = []; + } } - foundIp = true; + + return { + name: cfg.name || cfg.metric, + value: val + }; } + } catch (e) { + console.error(`[Prometheus] Custom metric error (${cfg.metric}):`, e.message); } - } catch (e) { - console.error(`[Prometheus] Error querying custom IP metric ${settings.ip_metric_name}:`, e.message); - } - } - - // 2. Fallback to Prometheus Targets API - if (!foundIp) { - try { - const targets = await getTargets(baseUrl); - const matchedTarget = targets.find(t => t.labels && t.labels.instance === node && t.labels.job === job); - if (matchedTarget) { - const scrapeUrl = matchedTarget.scrapeUrl || ''; - try { - const urlObj = new URL(scrapeUrl); - const host = urlObj.hostname; - if (host.includes(':')) { - results.ipv6 = [host]; - results.ipv4 = []; - } else { - results.ipv4 = [host]; - results.ipv6 = []; - } - foundIp = true; - } catch (e) { - // Simple fallback if URL parsing fails - const host = scrapeUrl.split('//').pop().split('/')[0].split(':')[0]; - if (host) { - results.ipv4 = [host]; - results.ipv6 = []; - foundIp = true; - } - } - } - } catch (e) { - console.error(`[Prometheus] Error fetching target info for ${node}:`, e.message); - } - } - - if (!foundIp) { - results.ipv4 = []; - results.ipv6 = []; + return null; + }); + + const customResults = await Promise.all(customPromises); + results.custom_data = customResults.filter(r => r !== null); } } catch (err) { - console.error(`[Prometheus] Critical error resolving IPs for ${node}:`, err.message); - results.ipv4 = results.ipv4 || []; - results.ipv6 = results.ipv6 || []; + console.error('[Prometheus] Error processing custom metrics:', err.message); } + // Ensure IP discovery fallback if no custom IP metric found + if ((!results.ipv4 || results.ipv4.length === 0) && (!results.ipv6 || results.ipv6.length === 0)) { + try { + const targets = await getTargets(baseUrl); + const matchedTarget = targets.find(t => t.labels && t.labels.instance === node && t.labels.job === job); + if (matchedTarget) { + const scrapeUrl = matchedTarget.scrapeUrl || ''; + try { + const urlObj = new URL(scrapeUrl); + const host = urlObj.hostname; + if (host.includes(':')) { + results.ipv6 = [host]; + results.ipv4 = []; + } else { + results.ipv4 = [host]; + results.ipv6 = []; + } + } catch (e) { + const host = scrapeUrl.split('//').pop().split('/')[0].split(':')[0]; + if (host) { + results.ipv4 = [host]; + results.ipv6 = []; + } + } + } + } catch (e) { + console.error(`[Prometheus] Target fallback error for ${node}:`, e.message); + } + } + + // Final sanitization + results.ipv4 = results.ipv4 || []; + results.ipv6 = results.ipv6 || []; + // Group partitions const partitionsMap = {}; (results.partitions_size || []).forEach(p => {