修复项目逻辑错误

新增数据库检查
This commit is contained in:
CN-JS-HuiBai
2026-04-10 23:42:55 +08:00
parent 2cd6c6ef27
commit 7362bcf206
7 changed files with 266 additions and 377 deletions

View File

@@ -587,7 +587,7 @@
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,未登录访客仍可看到大屏总览,但点击单台服务器时需要先登录。</p>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="showServerIpInput">服务器详情内是否显示 IPv4 和 IPv6 地址</label>
<label for="showServerIpInput">是否在服务器详情中显示公网 IP</label>
<select id="showServerIpInput"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
<option value="1">显示 (Show)</option>
@@ -595,23 +595,14 @@
</select>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,点击服务器详情时会显示该服务器的公网 IP 地址。</p>
</div>
<div style="margin-top: 25px; padding-top: 15px; border-top: 1px dashed var(--border-color);">
<h4 style="margin-bottom: 12px; color: var(--text-secondary); font-size: 0.95rem;">高级指标配置 (可选)</h4>
<div class="form-group">
<label for="ipMetricNameInput">IP 发现指标 (Metric Name)</label>
<input type="text" id="ipMetricNameInput" placeholder="例如: node_network_address_info"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">留空则使用 Prometheus Target 自动发现。若需兼容 <code>node_exporter</code> 自定义指标(如 textfile请在此输入指标名。</p>
</div>
<div class="form-group" style="margin-top: 12px;">
<label for="ipLabelNameInput">IP 提取标签 (Label Name)</label>
<input type="text" id="ipLabelNameInput" placeholder="address"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">从该指标的哪个标签提取 IP默认为 <code>address</code></p>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="ipMetricNameInput">自定义 IP 采集指标 (可选)</label>
<input type="text" id="ipMetricNameInput" placeholder="例node_network_address_info">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">如果您的 Prometheus 中有专门记录 IP 的指标,请在此输入。留空则尝试自动发现。</p>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="ipLabelNameInput">IP 指标中的 Label 名称</label>
<input type="text" id="ipLabelNameInput" placeholder="默认address">
</div>
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
<button class="btn btn-add" id="btnSaveSecuritySettings">保存安全设置</button>
@@ -620,6 +611,26 @@
</div>
</div>
<!-- Custom Detail Metrics Tab -->
<div class="tab-content" id="tab-details-metrics">
<div class="metrics-settings-form">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0;">服务器详情指标配置</h3>
<button class="btn btn-add" id="btnAddCustomMetric" style="padding: 6px 12px; font-size: 0.8rem;">
<i class="fas fa-plus"></i> 添加指标
</button>
</div>
<div id="customMetricsList" class="custom-metrics-list" style="max-height: 400px; overflow-y: auto; padding-right: 5px;">
<!-- Dynamic rows will be added here -->
</div>
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
<button class="btn btn-add" id="btnSaveCustomMetrics">保存指标配置</button>
</div>
</div>
</div>
<!-- Latency Routes Tab -->
<div class="tab-content" id="tab-latency">
<div class="latency-settings-form">

View File

@@ -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 = `
<div style="display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 10px; margin-bottom: 8px;">
<input type="text" placeholder="显示名 (如:内核)" class="metric-display-name" value="${config.name || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
<input type="text" placeholder="指标名 (PromQL)" class="metric-query" value="${config.metric || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
<input type="text" placeholder="取值标签" class="metric-label" value="${config.label || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<label style="font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" class="metric-is-ip" ${config.is_ip ? 'checked' : ''} style="margin-right: 5px;"> 设为 IP 发现源
</label>
<button class="btn-remove-metric" style="background: none; border: none; color: #ff4d4f; cursor: pointer; font-size: 0.75rem;">
<i class="fas fa-trash"></i> 删除
</button>
</div>
`;
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 @@
</div>
`).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 = `
<span class="detail-metric-label">${item.name}</span>
<span class="detail-metric-value">${item.value || '-'}</span>
`;
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

View File

@@ -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;

View File

@@ -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"
}
]
},

View File

@@ -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}`);

View File

@@ -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 => {

View File

@@ -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 => {