修复项目逻辑错误
新增数据库检查
This commit is contained in:
@@ -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">
|
||||
|
||||
106
public/js/app.js
106
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 = `
|
||||
<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
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user