1 Commits

Author SHA1 Message Date
CN-JS-HuiBai
e19a21a3cc 完善serverless部署环境 2026-04-10 14:40:24 +08:00
15 changed files with 1828 additions and 2418 deletions

3
api/index.js Normal file
View File

@@ -0,0 +1,3 @@
const { app } = require('../server/index');
module.exports = app;

View File

@@ -6,8 +6,7 @@
"scripts": {
"dev": "node server/index.js",
"start": "node server/index.js",
"init-db": "node server/init-db.js",
"db-migrate": "node server/init-db.js"
"init-db": "node server/init-db.js"
},
"dependencies": {
"axios": "^1.7.0",

View File

@@ -328,33 +328,6 @@ body {
transform: rotate(30deg);
}
/* ---- Global Refresh Button ---- */
.btn-refresh-global {
width: 40px;
height: 40px;
display: none;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s ease;
}
.btn-refresh-global:hover {
border-color: var(--border-hover);
color: var(--accent-emerald);
background: rgba(16, 185, 129, 0.08);
transform: translateY(-2px);
}
.btn-refresh-global svg {
width: 20px;
height: 20px;
}
/* ---- Theme Switch ---- */
.theme-switch-wrapper {
display: flex;
@@ -1485,7 +1458,7 @@ input:checked+.slider:before {
width: 100%;
height: 100vh;
height: 100dvh;
z-index: 9999;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
@@ -2778,21 +2751,7 @@ input:checked+.slider:before {
.filings {
display: flex;
align-items: center;
gap: 12px;
}
.filing-sep {
display: none;
width: 1px;
height: 12px;
background: var(--text-muted);
opacity: 0.3;
}
@media (min-width: 768px) {
.filing-sep {
display: block;
}
gap: 20px;
}
.filings a {
@@ -2830,84 +2789,3 @@ input:checked+.slider:before {
justify-content: center;
}
}
/* ---- Source Settings Toggles ---- */
/* ---- Source Settings Toggles ---- */
.source-options-clean-row {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 4px 0;
}
.source-option-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.source-option-item:hover .source-option-label {
color: var(--text-primary);
}
.source-option-label {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 500;
}
.switch-wrapper {
position: relative;
width: 38px;
height: 20px;
flex-shrink: 0;
}
.switch-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.switch-label {
position: absolute;
cursor: pointer;
inset: 0;
background-color: var(--bg-input);
transition: .35s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--border-color);
border-radius: 34px;
}
.switch-label:before {
position: absolute;
content: '';
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background-color: var(--text-muted);
transition: .35s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
}
.switch-input:checked + .switch-label {
background-color: rgba(99, 102, 241, 0.15);
border-color: var(--accent-indigo);
}
.switch-input:checked + .switch-label:before {
transform: translateX(18px);
background-color: var(--accent-indigo);
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
}
/* Light theme support */
:root.light-theme .switch-label:before {
background-color: #94a3b8;
}

View File

@@ -80,20 +80,6 @@
observer.observe(document.documentElement, { childList: true, subtree: true });
})();
</script>
<script>
// Global Error Logger for remote debugging
window.onerror = function(msg, url, line, col, error) {
var debugDiv = document.getElementById('js-debug-overlay');
if (!debugDiv) {
debugDiv = document.createElement('div');
debugDiv.id = 'js-debug-overlay';
debugDiv.style.cssText = 'position:fixed;top:0;left:0;width:100%;background:rgba(220,38,38,0.95);color:white;z-index:99999;padding:10px;font-family:monospace;font-size:12px;max-height:30vh;overflow:auto;pointer-events:none;';
document.body.appendChild(debugDiv);
}
debugDiv.innerHTML += '<div>[JS ERROR] ' + msg + ' at ' + line + ':' + col + '</div>';
return false;
};
</script>
</head>
<body>
@@ -155,13 +141,6 @@
<div id="userSection">
<button class="btn btn-login" id="btnLogin">登录</button>
</div>
<button class="btn-refresh-global" id="btnGlobalRefresh" title="全局强制刷新数据" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</button>
<button class="btn-settings" id="btnSettings" title="配置管理">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
@@ -399,13 +378,11 @@
<th class="sortable" data-sort="disk">磁盘 <span class="sort-icon"></span></th>
<th class="sortable" data-sort="netRx">网络 ↓ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="netTx">网络 ↑ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="conntrack">Conntrack <span class="sort-icon"></span></th>
<th class="sortable" data-sort="traffic24h">24h 流量 <span class="sort-icon"></span></th>
</tr>
</thead>
<tbody id="serverTableBody">
<tr class="empty-row">
<td colspan="10">暂无数据 - 请先配置 Prometheus 数据源</td>
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr>
</tbody>
</table>
@@ -435,9 +412,9 @@
<div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div>
<div class="filings">
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank" id="psFilingDisplay" style="display: none;">
<img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b1220'/%3E%3Cpath d='M32 10l18 8v12c0 11.6-7.2 21.9-18 26-10.8-4.1-18-14.4-18-26V18l18-8z' fill='%2310b981'/%3E%3Cpath d='M32 18l10 4.6v7.1c0 7-4.1 13.4-10 16.1-5.9-2.7-10-9.1-10-16.1v-7.1L32 18z' fill='%23ecfdf5'/%3E%3Cpath d='M28 31.5l3 3 6-6' fill='none' stroke='%2310b981' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" alt="公安备案" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 2px;">
<span id="psFilingText"></span>
</a>
<span class="filing-sep"></span>
<a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a>
</div>
</div>
@@ -450,7 +427,6 @@
<div class="modal-tabs">
<button class="modal-tab active" data-tab="prom">数据源管理</button>
<button class="modal-tab" data-tab="site">大屏设置</button>
<button class="modal-tab" data-tab="security">安全设置</button>
<button class="modal-tab" data-tab="latency">延迟线路管理</button>
<button class="modal-tab" data-tab="auth">账号安全</button>
</div>
@@ -483,31 +459,17 @@
<div class="form-row">
<div class="form-group form-group-wide">
<label for="sourceDesc">描述 (可选)</label>
<input type="text" id="sourceDesc" placeholder="记录关于此数据源的备注信息" autocomplete="off">
<input type="text" id="sourceDesc" placeholder="数据源描述" autocomplete="off">
</div>
</div>
<div class="form-row" id="serverSourceOption" style="margin-top: 4px;">
<div class="form-group form-group-wide">
<div class="source-options-clean-row">
<label class="source-option-item" title="将此数据源的服务器指标聚合到首页总览中">
<div class="switch-wrapper">
<input type="checkbox" id="isOverviewSource" checked class="switch-input">
<div class="switch-label"></div>
</div>
<span class="source-option-label">加入总览统计</span>
</label>
<label class="source-option-item" title="在服务器详情列表中显示此数据源的服务器">
<div class="switch-wrapper">
<input type="checkbox" id="isDetailSource" checked class="switch-input">
<div class="switch-label"></div>
</div>
<span class="source-option-label">加入详情展示</span>
</label>
</div>
<input type="checkbox" id="isServerSource" checked disabled style="display: none;">
<div class="form-group" id="serverSourceOption"
style="display: flex; align-items: flex-end; padding-bottom: 8px;">
<label
style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
<input type="checkbox" id="isServerSource" checked
style="width: 16px; height: 16px; accent-color: var(--accent-indigo);">
<span>用于服务器展示</span>
</label>
</div>
</div>
<div class="form-row" style="margin-top: 8px;">
<div class="form-actions">
<button class="btn btn-test" id="btnTest">测试连接</button>
<button class="btn btn-add" id="btnAdd">添加</button>
@@ -545,6 +507,15 @@
<option value="0">隐藏 (Hide)</option>
</select>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="requireLoginForServerDetailsInput">服务器详情是否仅登录后可查看</label>
<select id="requireLoginForServerDetailsInput"
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">仅登录后可查看</option>
<option value="0">允许公开查看</option>
</select>
<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="logoUrlInput">Logo URL (白天/默认,支持图片链接)</label>
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo_light.png">
@@ -588,11 +559,6 @@
<option value="max">出入取大 (Max)</option>
</select>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="prometheusCacheTtlInput">数据自动刷新/同步间隔 (秒)</label>
<input type="number" id="prometheusCacheTtlInput" placeholder="例30" min="0" max="86400">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">后端将按此频率主动从 Prometheus 抓取数据并缓存。设为 0 则禁用自动同步。建议值15-60s。</p>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="psFilingInput">公安备案号 (如:京公网安备 11010102000001号)</label>
<input type="text" id="psFilingInput" placeholder="请输入公安备案号">
@@ -601,11 +567,6 @@
<label for="icpFilingInput">ICP 备案号 (如京ICP备12345678号)</label>
<input type="text" id="icpFilingInput" placeholder="请输入 ICP 备案号">
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="cdnUrlInput">静态资源 CDN 地址 (例如: https://cdn.example.com)</label>
<input type="url" id="cdnUrlInput" placeholder="留空则使用本地服务器资源">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,页面中的 JS/CSS/图片等资源将尝试从该 CDN 加载。请确保 CDN 已正确镜像相关资源。</p>
</div>
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
<button class="btn btn-add" id="btnSaveSiteSettings">保存基础设置</button>
</div>
@@ -613,65 +574,6 @@
</div>
</div>
<!-- Security Settings Tab -->
<div class="tab-content" id="tab-security">
<div class="security-settings-form">
<h3>安全与隐私设置</h3>
<div class="form-group" style="margin-top: 15px;">
<label for="requireLoginForServerDetailsInput">服务器详情是否仅登录后可查看</label>
<select id="requireLoginForServerDetailsInput"
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">仅登录后可查看</option>
<option value="0">允许公开查看</option>
</select>
<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">是否在服务器详情中显示公网 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>
<option value="0">隐藏 (Hide)</option>
</select>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,点击服务器详情时会显示该服务器的公网 IP 地址。</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>
</div>
<div class="form-message" id="securitySettingsMessage"></div>
</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 class="form-message" id="customMetricsMessage"></div>
</div>
</div>
<!-- Latency Routes Tab -->
<div class="tab-content" id="tab-latency">
<div class="latency-settings-form">
@@ -687,7 +589,6 @@
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);">
<option value="">-- 选择数据源 --</option>
</select>
</div>
<div class="form-group">
<label>起航点</label>

View File

@@ -37,8 +37,6 @@
sourceDesc: document.getElementById('sourceDesc'),
btnTest: document.getElementById('btnTest'),
btnAdd: document.getElementById('btnAdd'),
isOverviewSource: document.getElementById('isOverviewSource'),
isDetailSource: document.getElementById('isDetailSource'),
isServerSource: document.getElementById('isServerSource'),
formMessage: document.getElementById('formMessage'),
sourceItems: document.getElementById('sourceItems'),
@@ -54,10 +52,7 @@
siteTitleInput: document.getElementById('siteTitleInput'),
logoUrlInput: document.getElementById('logoUrlInput'),
btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'),
btnSaveSecuritySettings: document.getElementById('btnSaveSecuritySettings'),
siteSettingsMessage: document.getElementById('siteSettingsMessage'),
securitySettingsMessage: document.getElementById('securitySettingsMessage'),
customMetricsMessage: document.getElementById('customMetricsMessage'),
logoText: document.getElementById('logoText'),
logoIconContainer: document.getElementById('logoIconContainer'),
defaultThemeInput: document.getElementById('defaultThemeInput'),
@@ -117,23 +112,13 @@
globeCard: document.getElementById('globeCard'),
btnExpandGlobe: document.getElementById('btnExpandGlobe'),
btnRefreshNetwork: document.getElementById('btnRefreshNetwork'),
btnGlobalRefresh: document.getElementById('btnGlobalRefresh'),
showServerIpInput: document.getElementById('showServerIpInput'),
ipMetricNameInput: document.getElementById('ipMetricNameInput'),
ipLabelNameInput: document.getElementById('ipLabelNameInput'),
// Footer & Filing
icpFilingInput: document.getElementById('icpFilingInput'),
psFilingInput: document.getElementById('psFilingInput'),
icpFilingDisplay: document.getElementById('icpFilingDisplay'),
psFilingDisplay: document.getElementById('psFilingDisplay'),
psFilingText: document.getElementById('psFilingText'),
copyrightYear: document.getElementById('copyrightYear'),
customMetricsList: document.getElementById('customMetricsList'),
btnAddCustomMetric: document.getElementById('btnAddCustomMetric'),
btnSaveCustomMetrics: document.getElementById('btnSaveCustomMetrics'),
customDataContainer: document.getElementById('customDataContainer'),
cdnUrlInput: document.getElementById('cdnUrlInput'),
prometheusCacheTtlInput: document.getElementById('prometheusCacheTtlInput')
copyrightYear: document.getElementById('copyrightYear')
};
// ---- State ----
@@ -153,7 +138,10 @@
let siteThemeQuery = null; // For media query cleanup
let siteThemeHandler = null;
let backgroundIntervals = []; // To track setIntervals
let realtimeIntervalId = null;
let lastMapDataHash = ''; // Cache for map rendering optimization
const appRuntime = window.APP_RUNTIME || {};
const prefersPollingRealtime = appRuntime.realtimeMode === 'polling';
// Load sort state from localStorage or use default
let currentSort = { column: 'up', direction: 'desc' };
@@ -168,8 +156,6 @@
let myMap2D = null;
let editingRouteId = null;
let allStoredSources = [];
let allStoredLatencyRoutes = [];
async function fetchJsonWithFallback(urls) {
let lastError = null;
@@ -189,77 +175,11 @@
throw lastError || new Error('All JSON sources failed');
}
// ---- 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;
}
// ---- Initialize ----
function init() {
try {
console.log('[Init] Start...');
// Clear existing intervals to prevent duplication on re-init
if (backgroundIntervals && backgroundIntervals.length > 0) {
backgroundIntervals.forEach(clearInterval);
}
backgroundIntervals = [];
// Resource Gauges Time
updateGaugesTime();
backgroundIntervals.push(setInterval(updateGaugesTime, 1000));
setInterval(updateGaugesTime, 1000);
// Initial footer year
if (dom.copyrightYear) {
@@ -285,7 +205,7 @@
dom.serverSourceOption.style.display = 'none';
dom.isServerSource.checked = false;
} else {
dom.serverSourceOption.style.display = '';
dom.serverSourceOption.style.display = 'flex';
dom.isServerSource.checked = true;
}
});
@@ -295,78 +215,49 @@
dom.btnCancelEditRoute.onclick = cancelEditRoute;
}
// Auth & Theme listeners
if (dom.themeToggle) dom.themeToggle.addEventListener('change', toggleTheme);
if (dom.btnSettings) dom.btnSettings.addEventListener('click', openSettings);
if (dom.modalClose) dom.modalClose.addEventListener('click', closeSettings);
if (dom.btnTest) dom.btnTest.addEventListener('click', testConnection);
if (dom.btnAdd) dom.btnAdd.addEventListener('click', addSource);
dom.settingsModal.addEventListener('click', (e) => {
if (e.target === dom.settingsModal) closeSettings();
});
dom.btnTest.addEventListener('click', testConnection);
dom.btnAdd.addEventListener('click', addSource);
// Auth & Login
if (dom.btnLogin) dom.btnLogin.addEventListener('click', openLoginModal);
if (dom.closeLoginModal) dom.closeLoginModal.addEventListener('click', closeLoginModal);
if (dom.loginForm) dom.loginForm.addEventListener('submit', handleLogin);
if (dom.loginModal) {
dom.loginModal.addEventListener('click', (e) => {
if (e.target === dom.loginModal) closeLoginModal();
// Auth & Theme listeners
dom.themeToggle.addEventListener('change', toggleTheme);
// System Theme Listener (Real-time)
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: light)');
if (systemThemeMedia.addEventListener) {
systemThemeMedia.addEventListener('change', () => {
const savedTheme = localStorage.getItem('theme') || (window.SITE_SETTINGS && window.SITE_SETTINGS.default_theme) || 'dark';
if (savedTheme === 'auto') {
applyTheme('auto');
}
});
}
dom.btnLogin.addEventListener('click', openLoginModal);
dom.closeLoginModal.addEventListener('click', closeLoginModal);
dom.loginForm.addEventListener('submit', handleLogin);
dom.loginModal.addEventListener('click', (e) => {
if (e.target === dom.loginModal) closeLoginModal();
});
// Tab switching
if (dom.modalTabs) {
dom.modalTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab');
switchTab(targetTab);
});
dom.modalTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab');
switchTab(targetTab);
});
}
});
// Site settings
if (dom.btnSaveSiteSettings) {
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
}
// Custom Metrics
if (dom.btnAddCustomMetric) {
dom.btnAddCustomMetric.addEventListener('click', () => addMetricRow());
}
if (dom.btnSaveCustomMetrics) {
dom.btnSaveCustomMetrics.addEventListener('click', saveSiteSettings);
}
if (dom.btnAddRoute) {
dom.btnAddRoute.addEventListener('click', addLatencyRoute);
}
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
dom.btnAddRoute.addEventListener('click', addLatencyRoute);
// Auth password change
if (dom.btnChangePassword) {
dom.btnChangePassword.addEventListener('click', saveChangePassword);
}
// Settings management event delegation
if (dom.sourceItems) {
dom.sourceItems.addEventListener('click', (e) => {
const btnEdit = e.target.closest('.btn-edit-source');
const btnDelete = e.target.closest('.btn-delete-source');
if (btnEdit) {
window.editSource(parseInt(btnEdit.getAttribute('data-id'), 10));
} else if (btnDelete) {
window.deleteSource(parseInt(btnDelete.getAttribute('data-id'), 10));
}
});
}
if (dom.latencyRoutesList) {
dom.latencyRoutesList.addEventListener('click', (e) => {
const btnEdit = e.target.closest('.btn-edit-route');
const btnDelete = e.target.closest('.btn-delete-route');
if (btnEdit) {
window.editRoute(parseInt(btnEdit.getAttribute('data-id'), 10));
} else if (btnDelete) {
window.deleteLatencyRoute(parseInt(btnDelete.getAttribute('data-id'), 10));
}
});
}
// Globe expansion (FLIP animation via Web Animations API)
let savedGlobeRect = null;
let globeAnimating = false;
@@ -486,28 +377,23 @@
});
}
const handleGlobalRefresh = async (btn) => {
const icon = btn.querySelector('svg');
if (icon) icon.style.animation = 'spin 0.8s ease-in-out';
await Promise.all([
fetchNetworkHistory(true),
fetchMetrics(true)
]);
if (icon) {
setTimeout(() => {
icon.style.animation = '';
}, 800);
}
};
if (dom.btnRefreshNetwork) {
dom.btnRefreshNetwork.addEventListener('click', () => handleGlobalRefresh(dom.btnRefreshNetwork));
}
dom.btnRefreshNetwork.addEventListener('click', async () => {
const icon = dom.btnRefreshNetwork.querySelector('svg');
if (icon) icon.style.animation = 'spin 0.8s ease-in-out';
if (dom.btnGlobalRefresh) {
dom.btnGlobalRefresh.addEventListener('click', () => handleGlobalRefresh(dom.btnGlobalRefresh));
// Force refresh all Prometheus 24h data and overview
await Promise.all([
fetchNetworkHistory(true),
fetchMetrics(true)
]);
if (icon) {
setTimeout(() => {
icon.style.animation = '';
}, 800);
}
});
}
// Keyboard shortcut
@@ -655,40 +541,36 @@
if (dom.psFilingInput) dom.psFilingInput.value = window.SITE_SETTINGS.ps_filing || '';
if (dom.logoUrlDarkInput) dom.logoUrlDarkInput.value = window.SITE_SETTINGS.logo_url_dark || '';
if (dom.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || '';
if (dom.showServerIpInput) dom.showServerIpInput.value = window.SITE_SETTINGS.show_server_ip ? "1" : "0";
if (dom.prometheusCacheTtlInput) dom.prometheusCacheTtlInput.value = window.SITE_SETTINGS.prometheus_cache_ttl !== undefined ? window.SITE_SETTINGS.prometheus_cache_ttl : 30;
// Apply security dependency
updateSecurityDependency();
// Load custom metrics
loadCustomMetricsUI(window.SITE_SETTINGS.custom_metrics);
// Latency routes loaded separately in openSettings or on startup
}
loadSiteSettings();
// Bind save button for security tab
if (dom.btnSaveSecuritySettings) {
dom.btnSaveSecuritySettings.addEventListener('click', saveSiteSettings);
}
// Security dependency listener
if (dom.requireLoginForServerDetailsInput) {
dom.requireLoginForServerDetailsInput.addEventListener('change', updateSecurityDependency);
}
// Track intervals for resource management
initWebSocket();
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
console.log('[Init] Success');
} catch (err) {
console.error('[Init Failure]', err);
if (window.onerror) window.onerror('Initialization failed: ' + err.message, '', 0, 0, err);
if (prefersPollingRealtime) {
startRealtimePolling();
} else {
initWebSocket();
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
}
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
}
// ---- Real-time WebSocket ----
function stopRealtimePolling() {
if (realtimeIntervalId) {
clearInterval(realtimeIntervalId);
realtimeIntervalId = null;
}
}
function startRealtimePolling() {
if (realtimeIntervalId) return;
fetchRealtimeOverview();
realtimeIntervalId = setInterval(fetchRealtimeOverview, REFRESH_INTERVAL);
backgroundIntervals.push(realtimeIntervalId);
}
function initWebSocket() {
if (isWsConnecting) return;
isWsConnecting = true;
@@ -706,6 +588,7 @@
ws.onopen = () => {
isWsConnecting = false;
stopRealtimePolling();
console.log('WS connection established');
};
@@ -726,6 +609,7 @@
ws.onclose = () => {
isWsConnecting = false;
startRealtimePolling();
console.log('WS connection closed. Reconnecting in 5s...');
setTimeout(initWebSocket, 5000);
};
@@ -736,14 +620,6 @@
};
}
function formatClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
return h + ':' + m + ':' + s;
}
// ---- Theme Switching ----
function toggleTheme() {
const theme = dom.themeToggle.checked ? 'light' : 'dark';
@@ -813,9 +689,7 @@
function updateUserUI(username) {
if (username) {
user = username;
if (dom.btnSettings) dom.btnSettings.style.display = 'flex';
if (dom.btnGlobalRefresh) dom.btnGlobalRefresh.style.display = 'flex';
dom.btnSettings.style.display = 'flex';
dom.userSection.innerHTML = `
<div class="user-info">
<span class="username">${escapeHtml(username)}</span>
@@ -825,9 +699,7 @@
document.getElementById('btnLogout').addEventListener('click', handleLogout);
} else {
user = null;
if (dom.btnSettings) dom.btnSettings.style.display = 'none';
if (dom.btnGlobalRefresh) dom.btnGlobalRefresh.style.display = 'none';
dom.btnSettings.style.display = 'none';
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
document.getElementById('btnLogin').addEventListener('click', openLoginModal);
}
@@ -908,16 +780,6 @@
if (resp.ok) {
updateUserUI(data.username);
closeLoginModal();
// Refresh data sources list for the filter dropdown
loadSourceCount();
// Refresh site settings (logo, filings, theme, etc.)
loadSiteSettings();
// Refresh dashboard data
fetchMetrics(true);
fetchNetworkHistory(true);
} else {
dom.loginError.textContent = data.error || '登录失败';
dom.loginError.style.display = 'block';
@@ -952,6 +814,19 @@
}
// ---- Fetch Metrics ----
async function fetchRealtimeOverview(force = false) {
try {
const url = `/api/realtime/overview${force ? '?force=true' : ''}`;
const response = await fetch(url);
const data = await response.json();
allServersData = data.servers || [];
currentLatencies = data.latencies || [];
updateDashboard(data);
} catch (err) {
console.error('Error fetching realtime overview:', err);
}
}
async function fetchMetrics(force = false) {
try {
const url = `/api/metrics/overview${force ? '?force=true' : ''}`;
@@ -1186,8 +1061,8 @@
'shanghai': [121.4737, 31.2304],
'hong kong': [114.1694, 22.3193],
'hk': [114.1694, 22.3193],
'taiwan': [121.5654, 25.0330],
'tw': [121.5654, 25.0330],
'taiwan': [120.9605, 23.6978],
'tw': [120.9605, 23.6978],
'united states': [-95.7129, 37.0902],
'us': [-95.7129, 37.0902],
'us seattle': [-122.3321, 47.6062],
@@ -1200,8 +1075,8 @@
'new york corp': [-74.0060, 40.7128],
'san francisco': [-122.4194, 37.7749],
'los angeles': [-118.2437, 34.0522],
'japan': [139.6917, 35.6895],
'jp': [139.6917, 35.6895],
'japan': [138.2529, 36.2048],
'jp': [138.2529, 36.2048],
'tokyo': [139.6917, 35.6895],
'singapore': [103.8198, 1.3521],
'sg': [103.8198, 1.3521],
@@ -1411,8 +1286,7 @@
</div>` },
{ key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(server.diskPercent) },
{ key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(server.netRx) },
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) },
{ key: 'conntrackUsedPct', label: 'Conntrack 占用比例', value: formatPercent(server.conntrackPercent) }
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) }
];
metrics.forEach(m => {
@@ -1502,14 +1376,6 @@
valA = a.netTx ?? 0;
valB = b.netTx ?? 0;
break;
case 'traffic24h':
valA = (a.traffic24hRx ?? 0) + (a.traffic24hTx ?? 0);
valB = (b.traffic24hRx ?? 0) + (b.traffic24hTx ?? 0);
break;
case 'conntrack':
valA = a.conntrackPercent ?? 0;
valB = b.conntrackPercent ?? 0;
break;
default:
valA = (a.job || '').toLowerCase();
valB = (b.job || '').toLowerCase();
@@ -1635,7 +1501,7 @@
if (!servers || servers.length === 0) {
dom.serverTableBody.innerHTML = `
<tr class="empty-row">
<td colspan="10">暂无数据 - 请先配置 Prometheus 数据源</td>
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr>
`;
return;
@@ -1680,18 +1546,6 @@
</td>
<td>${formatBandwidth(server.netRx)}</td>
<td>${formatBandwidth(server.netTx)}</td>
<td>
<div class="usage-bar">
<div class="usage-bar-track">
<div class="usage-bar-fill usage-bar-fill-cpu" style="width: ${Math.min(server.conntrackPercent || 0, 100)}%; background: var(--accent-indigo);"></div>
</div>
<span>${formatPercent(server.conntrackPercent || 0)}</span>
</div>
</td>
<td>
<div style="font-size: 0.85rem; font-weight: 500; color: var(--text-primary);">${formatBytes((server.traffic24hRx || 0) + (server.traffic24hTx || 0))}</div>
<div style="font-size: 0.65rem; color: var(--text-muted); margin-top: 2px;">↓${formatBytes(server.traffic24hRx || 0)} / ↑${formatBytes(server.traffic24hTx || 0)}</div>
</td>
</tr>
`;
}).join('');
@@ -1763,28 +1617,6 @@
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0);
}
// IP Addresses
const infoGrid = document.getElementById('detailInfoGrid');
if (infoGrid) {
// Remove any previously added IP items
infoGrid.querySelectorAll('.info-item-ip').forEach(el => el.remove());
if (window.SITE_SETTINGS && window.SITE_SETTINGS.show_server_ip) {
if (data.ipv4 && data.ipv4.length > 0) {
const ipv4Item = document.createElement('div');
ipv4Item.className = 'info-item info-item-ip';
ipv4Item.innerHTML = `<span class="info-label">IPv4 地址</span><span class="info-value">${escapeHtml(data.ipv4.join(', '))}</span>`;
infoGrid.appendChild(ipv4Item);
}
if (data.ipv6 && data.ipv6.length > 0) {
const ipv6Item = document.createElement('div');
ipv6Item.className = 'info-item info-item-ip';
ipv6Item.innerHTML = `<span class="info-label">IPv6 地址</span><span class="info-value" style="font-size: 0.65rem; word-break: break-all;">${escapeHtml(data.ipv6.join(', '))}</span>`;
infoGrid.appendChild(ipv6Item);
}
}
}
// Define metrics to show
const cpuValueHtml = `
<div style="display: flex; align-items: baseline; gap: 8px;">
@@ -1806,11 +1638,6 @@
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) },
{ key: 'sockstatTcp', label: 'TCP 链接数 (Sockstat)', value: data.sockstatTcp.toFixed(0) },
{ key: 'sockstatTcpMem', label: 'TCP 内存占用', value: formatBytes(data.sockstatTcpMem) },
{ key: 'conntrackUsedPct', label: 'Conntrack 占用比例', value: `
<div style="display: flex; align-items: baseline; gap: 8px;">
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(data.conntrackUsedPct)}</span>
<span style="font-size: 0.7rem; color: var(--text-secondary); font-weight: normal;">(${data.conntrackEntries.toFixed(0)} / ${data.conntrackLimit.toFixed(0)})</span>
</div>` },
{ key: 'networkTrend', label: '网络流量趋势 (24h)', value: '' }
];
@@ -1874,25 +1701,7 @@
</div>
`).join('');
// 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">${escapeHtml(item.name)}</span>
<span class="detail-metric-value">${escapeHtml(item.value || '-')}</span>
`;
customDataContainer.appendChild(card);
});
}
}
// Partitions
// Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric
if (data.partitions && data.partitions.length > 0) {
dom.detailPartitionsContainer.style.display = 'block';
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
@@ -1903,19 +1712,6 @@
diskMetricItem.after(dom.detailPartitionsContainer);
}
dom.detailPartitionsList.innerHTML = data.partitions.map(p => `
<div class="partition-row">
<div class="partition-info">
<span class="partition-name">${escapeHtml(p.mountpoint || p.device)}</span>
<span class="partition-usage-text">${escapeHtml(formatBytes(p.used))} / ${escapeHtml(formatBytes(p.total))}</span>
</div>
<div class="partition-bar-bg">
<div class="partition-bar-fill ${getUsageClass(p.percent)}" style="width: ${p.percent}%"></div>
</div>
<span class="partition-percent">${p.percent.toFixed(1)}%</span>
</div>
`).join('');
dom.partitionHeader.onclick = (e) => {
e.stopPropagation();
dom.detailPartitionsContainer.classList.toggle('active');
@@ -1973,7 +1769,6 @@
if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
if (metricKey.startsWith('net')) unit = 'B/s';
if (metricKey === 'sockstatTcpMem') unit = 'B';
if (metricKey === 'conntrackUsedPct') unit = '%';
if (metricKey === 'networkTrend') {
chart = new AreaChart(canvas);
@@ -1988,7 +1783,6 @@
if (metricKey === 'memUsedPct') chart.totalValue = currentServerDetail.memTotal;
if (metricKey === 'swapUsedPct') chart.totalValue = currentServerDetail.swapTotal;
if (metricKey === 'rootFsUsedPct') chart.totalValue = currentServerDetail.rootFsTotal;
if (metricKey === 'conntrackUsedPct') chart.totalValue = data.conntrackLimit;
try {
const { instance, job, source } = currentServerDetail;
@@ -2149,14 +1943,13 @@
if (dom.faviconUrlInput) dom.faviconUrlInput.value = settings.favicon_url || '';
if (dom.showPageNameInput) dom.showPageNameInput.value = settings.show_page_name !== undefined ? settings.show_page_name.toString() : "1";
if (dom.requireLoginForServerDetailsInput) dom.requireLoginForServerDetailsInput.value = settings.require_login_for_server_details ? "1" : "0";
if (dom.showServerIpInput) dom.showServerIpInput.value = settings.show_server_ip ? "1" : "0";
if (dom.cdnUrlInput) dom.cdnUrlInput.value = settings.cdn_url || '';
if (dom.prometheusCacheTtlInput) dom.prometheusCacheTtlInput.value = settings.prometheus_cache_ttl !== undefined ? settings.prometheus_cache_ttl : 30;
// Handle Theme Priority: localStorage > Site Default
const savedTheme = localStorage.getItem('theme');
const themeToApply = savedTheme || settings.default_theme || 'dark';
applyTheme(themeToApply);
// Listen for system theme changes if set to auto (cleanup existing listener first)
if (siteThemeQuery && siteThemeHandler) {
siteThemeQuery.removeEventListener('change', siteThemeHandler);
}
@@ -2171,24 +1964,12 @@
}
};
siteThemeQuery.addEventListener('change', siteThemeHandler);
// 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 || '';
// Load Custom Metrics
loadCustomMetricsUI(settings.custom_metrics);
// Sync security tab dependency
updateSecurityDependency();
} catch (err) {
console.error('Error loading site settings:', err);
}
}
function applySiteSettings(settings) {
if (!settings) return;
if (settings.page_name) {
document.title = settings.page_name;
}
@@ -2242,61 +2023,40 @@
}
// Filing info
let hasPs = !!settings.ps_filing;
let hasIcp = !!settings.icp_filing;
let hasFilings = false;
if (dom.psFilingDisplay) {
if (hasPs) {
if (settings.ps_filing) {
if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing;
dom.psFilingDisplay.style.display = 'inline-block';
hasFilings = true;
} else {
dom.psFilingDisplay.style.display = 'none';
}
}
if (dom.icpFilingDisplay) {
if (hasIcp) {
if (settings.icp_filing) {
dom.icpFilingDisplay.textContent = settings.icp_filing;
dom.icpFilingDisplay.style.display = 'inline-block';
hasFilings = true;
} else {
dom.icpFilingDisplay.style.display = 'none';
}
}
// Handle separator
const filingSep = document.querySelector('.filing-sep');
if (filingSep) {
// Small adjustment: the CSS will handle the PC-only display,
// here we just handle the logical "both exist" requirement.
filingSep.style.display = (hasPs && hasIcp) ? '' : 'none';
}
const footerContent = document.querySelector('.footer-content');
if (footerContent) {
footerContent.classList.toggle('only-copyright', !(hasPs || hasIcp));
footerContent.classList.toggle('only-copyright', !hasFilings);
}
}
async function saveSiteSettings(e) {
let messageTarget = dom.siteSettingsMessage;
if (e && e.target) {
if (e.target.id === 'btnSaveSecuritySettings') messageTarget = dom.securitySettingsMessage;
else if (e.target.id === 'btnSaveCustomMetrics') messageTarget = dom.customMetricsMessage;
}
async function saveSiteSettings() {
if (!user) {
showSiteMessage('请先登录后操作', 'error', messageTarget);
showSiteMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
const saveButtons = [dom.btnSaveSiteSettings, dom.btnSaveSecuritySettings, dom.btnSaveCustomMetrics].filter(b => b);
saveButtons.forEach(btn => {
btn.disabled = true;
btn.originalText = btn.textContent;
btn.textContent = '保存中...';
});
const settings = {
page_name: dom.pageNameInput.value.trim(),
title: dom.siteTitleInput ? dom.siteTitleInput.value.trim() : dom.pageNameInput.value.trim(),
@@ -2309,15 +2069,12 @@
show_95_bandwidth: dom.show95BandwidthInput ? (dom.show95BandwidthInput.value === "1") : false,
p95_type: dom.p95TypeSelect ? dom.p95TypeSelect.value : 'tx',
ps_filing: dom.psFilingInput ? dom.psFilingInput.value.trim() : '',
icp_filing: dom.icpFilingInput ? dom.icpFilingInput.value.trim() : '',
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',
custom_metrics: getCustomMetricsFromUI(),
cdn_url: dom.cdnUrlInput ? dom.cdnUrlInput.value.trim() : '',
prometheus_cache_ttl: dom.prometheusCacheTtlInput ? parseInt(dom.prometheusCacheTtlInput.value) : 30
icp_filing: dom.icpFilingInput ? dom.icpFilingInput.value.trim() : ''
};
dom.btnSaveSiteSettings.disabled = true;
dom.btnSaveSiteSettings.textContent = '保存中...';
try {
const response = await fetch('/api/settings', {
method: 'POST',
@@ -2326,52 +2083,23 @@
});
if (response.ok) {
const data = await response.json();
window.SITE_SETTINGS = data.settings;
applySiteSettings(window.SITE_SETTINGS);
showSiteMessage('设置保存成功', 'success', messageTarget);
showSiteMessage('设置保存成功', 'success');
// Update global object and UI immediately
window.SITE_SETTINGS = { ...window.SITE_SETTINGS, ...settings };
const savedTheme = localStorage.getItem('theme');
const themeToApply = savedTheme || settings.default_theme || 'dark';
applyTheme(themeToApply);
// Refresh overview and historical charts to reflect new source selections
fetchNetworkHistory(true);
fetch('/api/metrics/overview?force=true')
.then(res => res.json())
.then(data => updateDashboard(data))
.catch(() => {});
} else {
const err = await response.json();
showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error', messageTarget);
showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
showSiteMessage(`请求失败: ${err.message}`, 'error', messageTarget);
showSiteMessage(`保存失败: ${err.message}`, 'error');
console.error('Save settings error:', err);
} finally {
saveButtons.forEach(btn => {
btn.disabled = false;
btn.textContent = btn.originalText || '保存设置';
});
}
}
function updateSecurityDependency() {
if (!dom.requireLoginForServerDetailsInput || !dom.showServerIpInput) return;
const requireLogin = dom.requireLoginForServerDetailsInput.value === "1";
if (!requireLogin) {
// If public access is allowed, force hide IP and disable the toggle
dom.showServerIpInput.value = "0";
dom.showServerIpInput.disabled = true;
dom.showServerIpInput.style.opacity = "0.6";
dom.showServerIpInput.parentElement.style.opacity = "0.7";
} else {
// Re-enable when login is required
dom.showServerIpInput.disabled = false;
dom.showServerIpInput.style.opacity = "1";
dom.showServerIpInput.parentElement.style.opacity = "1";
dom.btnSaveSiteSettings.disabled = false;
dom.btnSaveSiteSettings.textContent = '保存设置';
}
}
@@ -2384,7 +2112,6 @@
return;
}
const routes = await response.json();
allStoredLatencyRoutes = routes;
renderLatencyRoutes(routes);
} catch (err) {
console.error('Error loading latency routes:', err);
@@ -2398,7 +2125,7 @@
}
dom.latencyRoutesList.innerHTML = routes.map(route => `
<div class="latency-route-item" data-id="${route.id}" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;">
<div class="latency-route-item" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;">
<div class="route-info" style="display: flex; flex-direction: column; gap: 4px;">
<div style="font-weight: 600; font-size: 0.88rem; color: var(--text-primary);">
${escapeHtml(route.latency_source)}${escapeHtml(route.latency_dest)}
@@ -2409,30 +2136,25 @@
</div>
</div>
<div class="route-actions" style="display: flex; gap: 8px;">
<button class="btn btn-test btn-edit-route" data-id="${route.id}" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button>
<button class="btn btn-delete btn-delete-route" data-id="${route.id}" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
<button class="btn btn-test" onclick="editRoute(${route.id}, ${route.source_id}, '${escapeHtml(route.latency_source)}', '${escapeHtml(route.latency_dest)}', '${escapeHtml(route.latency_target)}')" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button>
<button class="btn btn-delete" onclick="deleteLatencyRoute(${route.id})" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
</div>
</div>
`).join('');
}
window.editRoute = function (id, source_id, source, dest, target) {
let route = null;
if (source_id === undefined && allStoredLatencyRoutes) {
route = allStoredLatencyRoutes.find(r => r.id === id);
}
editingRouteId = id;
dom.routeSourceSelect.value = route ? route.source_id : (source_id || '');
dom.routeSourceInput.value = route ? route.latency_source : (source || '');
dom.routeDestInput.value = route ? route.latency_dest : (dest || '');
dom.routeTargetInput.value = route ? route.latency_target : (target || '');
dom.routeSourceSelect.value = source_id;
dom.routeSourceInput.value = source;
dom.routeDestInput.value = dest;
dom.routeTargetInput.value = target;
dom.btnAddRoute.textContent = '保存修改';
dom.btnCancelEditRoute.style.display = 'block';
// Select the tab 'latency' (not 'routes')
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'latency');
// Select the tab just in case (though it's already there)
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
if (tab) tab.click();
};
@@ -2511,23 +2233,14 @@
}
};
function showSiteMessage(text, type, target = null) {
const el = target || dom.siteSettingsMessage;
if (!el) return;
el.textContent = text;
el.className = `form-message ${type}`;
// Clear existing timeout if any (simplified)
if (el._msgTimeout) clearTimeout(el._msgTimeout);
el._msgTimeout = setTimeout(() => {
el.className = 'form-message';
el._msgTimeout = null;
}, 5000);
function showSiteMessage(text, type) {
dom.siteSettingsMessage.textContent = text;
dom.siteSettingsMessage.className = `form-message ${type}`;
setTimeout(hideSiteMessage, 5000);
}
function hideSiteMessage(target = null) {
const el = target || dom.siteSettingsMessage;
if (el) el.className = 'form-message';
function hideSiteMessage() {
dom.siteSettingsMessage.className = 'form-message';
}
async function saveChangePassword() {
@@ -2629,20 +2342,13 @@
async function loadSources() {
try {
if (dom.sourceItems) {
dom.sourceItems.innerHTML = '<div class="source-loading"><div class="dot dot-pulse"></div><span>正在加载数据源...</span></div>';
}
const response = await fetch('/api/sources');
if (response.status === 401) {
if (dom.sourceItems) {
dom.sourceItems.innerHTML = '<div class="source-empty">请登录后管理数据源</div>';
}
promptLogin('登录后可查看和管理数据源');
return;
}
const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : [];
allStoredSources = sourcesArray;
const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`;
updateSourceFilterOptions(sourcesArray);
@@ -2666,24 +2372,21 @@
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
${source.status === 'online' ? '在线' : '离线'}
</span>
${source.type === 'blackbox' ? '<span class="source-type-badge type-other">Blackbox</span>' : `
${source.is_overview_source ? '<span class="source-type-badge type-server" style="background: var(--accent-indigo);">总览</span>' : ''}
${source.is_detail_source ? '<span class="source-type-badge type-server" style="background: var(--accent-emerald);">详情</span>' : ''}
${!source.is_overview_source && !source.is_detail_source ? '<span class="source-type-badge type-other">独立数据源</span>' : ''}
`}
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
</span>
</div>
<div class="source-item-url">${escapeHtml(source.url)}</div>
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
</div>
<div class="source-item-actions">
<button class="btn btn-secondary btn-sm btn-edit-source" data-id="${source.id}">编辑</button>
<button class="btn btn-delete btn-sm btn-delete-source" data-id="${source.id}">删除</button>
<button class="btn btn-secondary btn-sm" onclick="editSource(${JSON.stringify(source).replace(/"/g, '&quot;')})">编辑</button>
<button class="btn btn-delete btn-sm" onclick="deleteSource(${source.id})">删除</button>
</div>
</div>
`).join('');
}
// ---- Test Connection ----
async function testConnection() {
const url = dom.sourceUrl.value.trim();
@@ -2725,26 +2428,19 @@
// ---- Add Source ----
let editingSourceId = null;
window.editSource = function(sourceOrId) {
let source = sourceOrId;
if (typeof sourceOrId !== 'object' && allStoredSources) {
source = allStoredSources.find(s => s.id === sourceOrId);
}
if (!source) return;
window.editSource = function(source) {
editingSourceId = source.id;
dom.sourceName.value = source.name || '';
dom.sourceUrl.value = source.url || '';
dom.sourceType.value = source.type || 'prometheus';
dom.sourceDesc.value = source.description || '';
if (dom.isOverviewSource) dom.isOverviewSource.checked = !!source.is_overview_source;
if (dom.isDetailSource) dom.isDetailSource.checked = !!source.is_detail_source;
dom.isServerSource.checked = !!source.is_server_source;
// Toggle Blackbox UI
if (source.type === 'blackbox') {
dom.serverSourceOption.style.display = 'none';
} else {
dom.serverSourceOption.style.display = '';
dom.serverSourceOption.style.display = 'flex';
}
dom.btnAdd.textContent = '保存修改';
@@ -2770,9 +2466,8 @@
dom.sourceUrl.value = '';
dom.sourceType.value = 'prometheus';
dom.sourceDesc.value = '';
if (dom.isOverviewSource) dom.isOverviewSource.checked = true;
if (dom.isDetailSource) dom.isDetailSource.checked = true;
dom.serverSourceOption.style.display = '';
dom.isServerSource.checked = true;
dom.serverSourceOption.style.display = 'flex';
dom.btnAdd.textContent = '添加';
const cancelBtn = document.getElementById('btnCancelEditSource');
@@ -2792,9 +2487,7 @@
const type = dom.sourceType.value;
const description = dom.sourceDesc.value.trim();
// Default to false for blackbox, otherwise use checkbox
const is_overview_source = type === 'blackbox' ? false : dom.isOverviewSource.checked;
const is_detail_source = type === 'blackbox' ? false : dom.isDetailSource.checked;
const is_server_source = is_overview_source || is_detail_source;
const is_server_source = type === 'blackbox' ? false : dom.isServerSource.checked;
if (!name || !url) {
showMessage('请填写名称和URL', 'error');
@@ -2812,7 +2505,7 @@
const response = await fetch(urlPath, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, url, description, is_server_source, is_overview_source, is_detail_source, type })
body: JSON.stringify({ name, url, description, is_server_source, type })
});
if (response.ok) {

View File

@@ -23,86 +23,9 @@ class AreaChart {
// Use debounced resize for performance and safety
this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this);
window.addEventListener('resize', this._resize);
// Drag zoom support
this.isDraggingP95 = false;
this.customMaxVal = null;
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
this.resize();
}
onPointerDown(e) {
if (!this.showP95 || !this.p95) return;
const rect = this.canvas.getBoundingClientRect();
const scaleY = this.height / rect.height;
const y = (e.clientY - rect.top) * scaleY;
const p = this.padding;
const chartH = this.height - p.top - p.bottom;
// Calculate current P95 Y position
const k = 1024;
const currentMaxVal = (this.customMaxVal !== null ? this.customMaxVal : (this.currentMaxVal || 1024));
let unitIdx = Math.floor(Math.log(Math.max(1, currentMaxVal)) / Math.log(k));
unitIdx = Math.max(0, Math.min(unitIdx, 4));
const unitFactor = Math.pow(k, unitIdx);
const rawValInUnit = (currentMaxVal * 1.15) / unitFactor;
let niceMaxInUnit;
if (rawValInUnit <= 1) niceMaxInUnit = 1;
else if (rawValInUnit <= 2) niceMaxInUnit = 2;
else if (rawValInUnit <= 5) niceMaxInUnit = 5;
else if (rawValInUnit <= 10) niceMaxInUnit = 10;
else if (rawValInUnit <= 20) niceMaxInUnit = 20;
else if (rawValInUnit <= 50) niceMaxInUnit = 50;
else if (rawValInUnit <= 100) niceMaxInUnit = 100;
else if (rawValInUnit <= 200) niceMaxInUnit = 200;
else if (rawValInUnit <= 500) niceMaxInUnit = 500;
else if (rawValInUnit <= 1000) niceMaxInUnit = 1000;
else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100;
const displayMaxVal = this.customMaxVal !== null ? this.customMaxVal : (niceMaxInUnit * unitFactor);
const p95Y = p.top + chartH - (this.p95 / (displayMaxVal || 1)) * chartH;
if (Math.abs(y - p95Y) < 25) {
this.isDraggingP95 = true;
this.canvas.style.cursor = 'ns-resize';
this.canvas.setPointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
}
}
onPointerMove(e) {
if (!this.isDraggingP95) return;
const rect = this.canvas.getBoundingClientRect();
const scaleY = this.height / rect.height;
const y = (e.clientY - rect.top) * scaleY;
const p = this.padding;
const chartH = this.height - p.top - p.bottom;
const dy = p.top + chartH - y;
if (dy > 10) {
this.customMaxVal = (this.p95 * chartH) / dy;
this.draw();
}
}
onPointerUp(e) {
if (this.isDraggingP95) {
this.isDraggingP95 = false;
this.canvas.style.cursor = '';
this.canvas.releasePointerCapture(e.pointerId);
}
}
resize() {
const rect = this.canvas.parentElement.getBoundingClientRect();
this.width = rect.width;
@@ -241,10 +164,13 @@ class AreaChart {
let unitIdx = Math.floor(Math.log(Math.max(1, maxDataVal)) / Math.log(k));
unitIdx = Math.max(0, Math.min(unitIdx, sizes.length - 1));
const unitFactor = Math.pow(k, unitIdx);
const unitLabel = sizes[unitIdx];
// Get value in current units and find a "nice" round max
// Use 1.15 cushion
const rawValInUnit = (maxDataVal * 1.15) / unitFactor;
let niceMaxInUnit;
if (rawValInUnit <= 1) niceMaxInUnit = 1;
else if (rawValInUnit <= 2) niceMaxInUnit = 2;
else if (rawValInUnit <= 5) niceMaxInUnit = 5;
@@ -257,16 +183,7 @@ class AreaChart {
else if (rawValInUnit <= 1000) niceMaxInUnit = 1000;
else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100;
let maxVal = niceMaxInUnit * unitFactor;
if (this.customMaxVal !== null) {
maxVal = this.customMaxVal;
}
// Recalculate units based on final maxVal (could be zoomed)
let finalUnitIdx = Math.floor(Math.log(Math.max(1, maxVal)) / Math.log(k));
finalUnitIdx = Math.max(0, Math.min(finalUnitIdx, sizes.length - 1));
const finalFactor = Math.pow(k, finalUnitIdx);
const finalUnitLabel = sizes[finalUnitIdx];
const maxVal = niceMaxInUnit * unitFactor;
const len = timestamps.length;
const xStep = chartW / (len - 1);
@@ -284,21 +201,21 @@ class AreaChart {
ctx.lineWidth = 1;
const gridLines = 4;
for (let i = 0; i <= gridLines; i++) {
const y = p.top + (chartH / gridLines) * i;
ctx.beginPath();
ctx.moveTo(p.left, y);
ctx.lineTo(p.left + chartW, y);
ctx.stroke();
const y = p.top + (chartH / gridLines) * i;
ctx.beginPath();
ctx.moveTo(p.left, y);
ctx.lineTo(p.left + chartW, y);
ctx.stroke();
// Y-axis labels
const v = maxVal * (1 - i / gridLines);
const valInUnit = v / finalFactor;
ctx.fillStyle = '#5a6380';
ctx.font = '10px "JetBrains Mono", monospace';
ctx.textAlign = 'right';
// Y-axis labels - share the same unit for readability
const valInUnit = niceMaxInUnit * (1 - i / gridLines);
ctx.fillStyle = '#5a6380';
ctx.font = '10px "JetBrains Mono", monospace';
ctx.textAlign = 'right';
const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + finalUnitLabel;
ctx.fillText(label, p.left - 10, y + 3);
// Format: "X.X MB/s" or "X MB/s"
const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + unitLabel;
ctx.fillText(label, p.left - 10, y + 3);
}
// X-axis labels (every ~4 hours)
@@ -310,42 +227,47 @@ class AreaChart {
const x = getX(i);
ctx.fillText(formatTime(timestamps[i]), x, h - 8);
}
// Always show last label
ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8);
// Draw data areas with clipping
ctx.save();
ctx.beginPath();
ctx.rect(p.left, p.top, chartW, chartH);
ctx.clip();
const getPVal = (arr, i) => (arr && i < arr.length) ? arr[i] : 0;
// Draw TX area
if (this.showTx) {
this.drawArea(ctx, tx, this.prevData ? this.prevData.tx : null, getX, getY, chartH, p,
'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)', '#6366f1', len);
'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)',
'#6366f1', len);
}
// Draw RX area (on top)
if (this.showRx) {
this.drawArea(ctx, rx, this.prevData ? this.prevData.rx : null, getX, getY, chartH, p,
'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', '#06b6d4', len);
'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)',
'#06b6d4', len);
}
ctx.restore();
// Draw P95 line
if (this.showP95 && this.p95 && (this.animProgress === 1 || this.isDraggingP95)) {
if (this.showP95 && this.p95 && this.animProgress === 1) {
const p95Y = getY(this.p95);
// Only draw if within visible range
if (p95Y >= p.top && p95Y <= p.top + chartH) {
ctx.save();
ctx.beginPath();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)';
ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)'; // --accent-rose
ctx.lineWidth = 1.5;
ctx.moveTo(p.left, p95Y);
ctx.lineTo(p.left + chartW, p95Y);
ctx.stroke();
// P95 label background
const label = '95计费: ' + (window.formatBandwidth ? window.formatBandwidth(this.p95) : this.p95.toFixed(2));
ctx.font = 'bold 11px "JetBrains Mono", monospace';
const metrics = ctx.measureText(label);
ctx.fillStyle = 'rgba(244, 63, 94, 0.15)';
ctx.fillRect(p.left + 8, p95Y - 20, metrics.width + 12, 18);
// P95 label text
ctx.fillStyle = '#f43f5e';
ctx.textAlign = 'left';
ctx.fillText(label, p.left + 14, p95Y - 7);

View File

@@ -0,0 +1,197 @@
/**
* 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

@@ -2,10 +2,17 @@
* Database schema check
* Ensures required tables and columns exist at startup.
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
require('dotenv').config();
const db = require('./db');
const fs = require('fs');
const IS_SERVERLESS = [
process.env.SERVERLESS,
process.env.VERCEL,
process.env.AWS_LAMBDA_FUNCTION_NAME,
process.env.NETLIFY,
process.env.FUNCTION_TARGET,
process.env.K_SERVICE
].some(Boolean);
const SCHEMA = {
users: {
@@ -18,11 +25,7 @@ const SCHEMA = {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: [
{ name: 'username', sql: "ALTER TABLE users ADD COLUMN username VARCHAR(255) NOT NULL UNIQUE AFTER id" },
{ name: 'password', sql: "ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL AFTER username" },
{ name: 'salt', sql: "ALTER TABLE users ADD COLUMN salt VARCHAR(255) NOT NULL AFTER password" }
]
columns: []
},
prometheus_sources: {
createSql: `
@@ -32,30 +35,29 @@ const SCHEMA = {
url VARCHAR(500) NOT NULL,
description TEXT,
is_server_source TINYINT(1) DEFAULT 1,
is_overview_source TINYINT(1) DEFAULT 1,
is_detail_source TINYINT(1) DEFAULT 1,
type VARCHAR(50) DEFAULT 'prometheus',
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
`,
columns: [
{ name: 'name', sql: "ALTER TABLE prometheus_sources ADD COLUMN name VARCHAR(255) NOT NULL AFTER id" },
{ name: 'url', sql: "ALTER TABLE prometheus_sources ADD COLUMN url VARCHAR(500) NOT NULL AFTER name" },
{ name: 'description', sql: "ALTER TABLE prometheus_sources ADD COLUMN description TEXT AFTER url" },
{ name: 'is_server_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description" },
{ name: 'is_overview_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_overview_source TINYINT(1) DEFAULT 1 AFTER is_server_source" },
{ name: 'is_detail_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_detail_source TINYINT(1) DEFAULT 1 AFTER is_overview_source" },
{ name: 'type', sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_detail_source" }
{
name: 'is_server_source',
sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description"
},
{
name: 'type',
sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source"
}
]
},
site_settings: {
createSql: `
CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY DEFAULT 1,
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
page_name VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
show_page_name TINYINT(1) DEFAULT 1,
title VARCHAR(255) DEFAULT '数据可视化展示大屏',
title VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
logo_url TEXT,
logo_url_dark TEXT,
favicon_url TEXT,
@@ -69,45 +71,65 @@ const SCHEMA = {
latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
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,
cdn_url VARCHAR(500) DEFAULT NULL,
prometheus_cache_ttl INT DEFAULT 30,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
seedSql: `
INSERT IGNORE INTO site_settings (
id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details, prometheus_cache_ttl
id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details
) VALUES (
1, '数据可视化展示大屏', 1, '数据可视化展示大屏', 'dark', 0, 'tx', 1, 30
1, 'Data Visualization Display Wall', 1, 'Data Visualization Display Wall', 'dark', 0, 'tx', 1
)
`,
columns: [
{ name: 'page_name', sql: "ALTER TABLE site_settings ADD COLUMN page_name VARCHAR(255) DEFAULT '数据可视化展示大屏' AFTER id" },
{ name: 'show_page_name', sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name" },
{ name: 'title', sql: "ALTER TABLE site_settings ADD COLUMN title VARCHAR(255) DEFAULT '数据可视化展示大屏' AFTER show_page_name" },
{ name: 'logo_url', sql: "ALTER TABLE site_settings ADD COLUMN logo_url TEXT AFTER title" },
{ name: 'logo_url_dark', sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url" },
{ name: 'favicon_url', sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark" },
{ name: 'default_theme', sql: "ALTER TABLE site_settings ADD COLUMN default_theme VARCHAR(20) DEFAULT 'dark' AFTER favicon_url" },
{ name: 'show_95_bandwidth', sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme" },
{ name: 'p95_type', sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth" },
{ name: 'require_login_for_server_details', sql: "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type" },
{ name: 'blackbox_source_id', sql: "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER require_login_for_server_details" },
{ name: 'latency_source', sql: "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id" },
{ name: 'latency_dest', sql: "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source" },
{ name: 'latency_target', sql: "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest" },
{ name: 'icp_filing', sql: "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target" },
{ name: 'ps_filing', sql: "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing" },
{ name: 'show_server_ip', sql: "ALTER TABLE site_settings ADD COLUMN show_server_ip TINYINT(1) DEFAULT 0 AFTER ps_filing" },
{ name: 'ip_metric_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_metric_name VARCHAR(100) DEFAULT NULL AFTER show_server_ip" },
{ name: 'ip_label_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_label_name VARCHAR(100) DEFAULT 'address' AFTER ip_metric_name" },
{ name: 'custom_metrics', sql: "ALTER TABLE site_settings ADD COLUMN custom_metrics JSON DEFAULT NULL AFTER ip_label_name" },
{ name: 'cdn_url', sql: "ALTER TABLE site_settings ADD COLUMN cdn_url VARCHAR(500) DEFAULT NULL AFTER custom_metrics" },
{ name: 'prometheus_cache_ttl', sql: "ALTER TABLE site_settings ADD COLUMN prometheus_cache_ttl INT DEFAULT 30 AFTER cdn_url" }
{
name: 'show_page_name',
sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name"
},
{
name: 'logo_url_dark',
sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url"
},
{
name: 'favicon_url',
sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark"
},
{
name: 'show_95_bandwidth',
sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme"
},
{
name: 'p95_type',
sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"
},
{
name: 'require_login_for_server_details',
sql: "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type"
},
{
name: 'blackbox_source_id',
sql: "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER require_login_for_server_details"
},
{
name: 'latency_source',
sql: "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id"
},
{
name: 'latency_dest',
sql: "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source"
},
{
name: 'latency_target',
sql: "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest"
},
{
name: 'icp_filing',
sql: "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target"
},
{
name: 'ps_filing',
sql: "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing"
}
]
},
traffic_stats: {
@@ -122,12 +144,7 @@ const SCHEMA = {
UNIQUE INDEX (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: [
{ name: 'rx_bytes', sql: "ALTER TABLE traffic_stats ADD COLUMN rx_bytes BIGINT UNSIGNED DEFAULT 0 AFTER id" },
{ name: 'tx_bytes', sql: "ALTER TABLE traffic_stats ADD COLUMN tx_bytes BIGINT UNSIGNED DEFAULT 0 AFTER rx_bytes" },
{ name: 'rx_bandwidth', sql: "ALTER TABLE traffic_stats ADD COLUMN rx_bandwidth DOUBLE DEFAULT 0 AFTER tx_bytes" },
{ name: 'tx_bandwidth', sql: "ALTER TABLE traffic_stats ADD COLUMN tx_bandwidth DOUBLE DEFAULT 0 AFTER rx_bandwidth" }
]
columns: []
},
server_locations: {
createSql: `
@@ -143,15 +160,7 @@ const SCHEMA = {
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: [
{ name: 'ip', sql: "ALTER TABLE server_locations ADD COLUMN ip VARCHAR(255) NOT NULL UNIQUE AFTER id" },
{ name: 'country', sql: "ALTER TABLE server_locations ADD COLUMN country CHAR(2) AFTER ip" },
{ name: 'country_name', sql: "ALTER TABLE server_locations ADD COLUMN country_name VARCHAR(100) AFTER country" },
{ name: 'region', sql: "ALTER TABLE server_locations ADD COLUMN region VARCHAR(100) AFTER country_name" },
{ name: 'city', sql: "ALTER TABLE server_locations ADD COLUMN city VARCHAR(100) AFTER region" },
{ name: 'latitude', sql: "ALTER TABLE server_locations ADD COLUMN latitude DOUBLE AFTER city" },
{ name: 'longitude', sql: "ALTER TABLE server_locations ADD COLUMN longitude DOUBLE AFTER latitude" }
]
columns: []
},
latency_routes: {
createSql: `
@@ -164,75 +173,66 @@ const SCHEMA = {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: [
{ name: 'source_id', sql: "ALTER TABLE latency_routes ADD COLUMN source_id INT NOT NULL AFTER id" },
{ name: 'latency_source', sql: "ALTER TABLE latency_routes ADD COLUMN latency_source VARCHAR(100) NOT NULL AFTER source_id" },
{ name: 'latency_dest', sql: "ALTER TABLE latency_routes ADD COLUMN latency_dest VARCHAR(100) NOT NULL AFTER latency_source" },
{ name: 'latency_target', sql: "ALTER TABLE latency_routes ADD COLUMN latency_target VARCHAR(255) NOT NULL AFTER latency_dest" }
]
columns: []
}
};
async function ensureTable(tableName, tableSchema) {
async function addColumnIfMissing(tableName, existingColumns, column) {
if (existingColumns.has(column.name)) {
return;
}
try {
// 1. Ensure table exists
await db.query(tableSchema.createSql);
// 2. Check columns
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
const existingColumns = new Set(columns.map((column) => column.Field));
console.log(`[Database Integrity] Table '${tableName}' verified (${columns.length} columns)`);
for (const column of tableSchema.columns || []) {
if (!existingColumns.has(column.name)) {
console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`);
await db.query(column.sql);
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}'.`);
}
}
// 3. Seed data
if (tableSchema.seedSql) {
const [rows] = await db.query(`SELECT count(*) as count FROM \`${tableName}\``);
if (rows[0].count === 0) {
console.log(`[Database Integrity] Table '${tableName}' is empty. Seeding initial data...`);
await db.query(tableSchema.seedSql);
}
}
console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`);
await db.query(column.sql);
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}'.`);
} catch (err) {
console.error(`[Database Integrity] Error ensuring table '${tableName}':`, err.message);
throw err;
console.error(`[Database Integrity] Failed to add '${tableName}.${column.name}':`, err.message);
if (column.sql.includes(' AFTER ')) {
try {
const fallbackSql = column.sql.split(' AFTER ')[0];
await db.query(fallbackSql);
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}' via fallback.`);
} catch (fallbackErr) {
console.error(`[Database Integrity] Fallback failed for '${tableName}.${column.name}':`, fallbackErr.message);
}
}
}
}
async function db_migrate() {
console.log('[Database Integrity] Starting comprehensive database audit...');
async function ensureTable(tableName, tableSchema) {
await db.query(tableSchema.createSql);
// Try to check if we can even connect
try {
const health = await db.checkHealth();
if (health.status !== 'up') {
console.warn(`[Database Integrity] initial health check failed: ${health.error}`);
// If we can't connect, maybe the DB itself doesn't exist?
// For now, we rely on the pool to handle connection retries/errors.
}
} catch (e) {
// Ignore health check errors, let ensureTable handle the primary queries
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
const existingColumns = new Set(columns.map((column) => column.Field));
for (const column of tableSchema.columns || []) {
await addColumnIfMissing(tableName, existingColumns, column);
}
if (tableSchema.seedSql) {
await db.query(tableSchema.seedSql);
}
}
async function checkAndFixDatabase() {
const autoSchemaSync = process.env.DB_AUTO_SCHEMA_SYNC
? process.env.DB_AUTO_SCHEMA_SYNC === 'true'
: !IS_SERVERLESS;
const hasDbConfig = Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE);
if (!hasDbConfig || !autoSchemaSync) {
return;
}
try {
let tablesChecked = 0;
for (const [tableName, tableSchema] of Object.entries(SCHEMA)) {
await ensureTable(tableName, tableSchema);
tablesChecked++;
}
console.log(`[Database Integrity] Audit complete. ${tablesChecked} tables verified and healthy.`);
return true;
} catch (err) {
console.error('[Database Integrity] ❌ Audit failed:', err.message);
throw err;
console.error('[Database Integrity] Startup schema check failed:', err.message);
}
}
module.exports = db_migrate;
module.exports = checkAndFixDatabase;

View File

@@ -1,37 +1,70 @@
const mysql = require('mysql2/promise');
let pool;
const IS_SERVERLESS = [
process.env.SERVERLESS,
process.env.VERCEL,
process.env.AWS_LAMBDA_FUNCTION_NAME,
process.env.NETLIFY,
process.env.FUNCTION_TARGET,
process.env.K_SERVICE
].some(Boolean);
function initPool() {
if (pool) {
pool.end().catch(e => console.error('Error closing pool:', e));
function getConnectionLimit() {
const parsed = parseInt(process.env.MYSQL_CONNECTION_LIMIT, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
return parsed;
}
pool = mysql.createPool({
return IS_SERVERLESS ? 2 : 10;
}
function createPool() {
return mysql.createPool({
host: process.env.MYSQL_HOST || 'localhost',
port: parseInt(process.env.MYSQL_PORT) || 3306,
port: parseInt(process.env.MYSQL_PORT, 10) || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'display_wall',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
connectionLimit: getConnectionLimit(),
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0
});
}
function getPool() {
if (!pool) {
pool = createPool();
}
return pool;
}
function initPool({ force = false } = {}) {
if (pool && !force) {
return pool;
}
if (pool) {
pool.end().catch(e => console.error('Error closing pool:', e));
}
pool = createPool();
return pool;
}
async function checkHealth() {
try {
if (!pool) return { status: 'down', error: 'Database pool not initialized' };
await pool.query('SELECT 1');
await getPool().query('SELECT 1');
return { status: 'up' };
} catch (err) {
return { status: 'down', error: err.message };
}
}
initPool();
module.exports = {
query: (...args) => pool.query(...args),
query: (...args) => getPool().query(...args),
getPool,
initPool,
checkHealth
};

View File

@@ -18,48 +18,21 @@ const enableExternalGeoLookup = process.env.ENABLE_EXTERNAL_GEO_LOOKUP === 'true
function normalizeGeo(geo) {
if (!geo) return geo;
// Custom normalization for TW to "Taipei, China" and JP to "Tokyo"
const country = (geo.country || geo.country_code || '').toUpperCase();
if (country === 'TW') {
// Custom normalization for TW, HK, MO to "China, {CODE}"
const specialRegions = ['TW'];
if (specialRegions.includes(geo.country?.toUpperCase())) {
return {
...geo,
city: 'Taipei',
country: 'TW',
country_name: 'China',
// Force Taipei coordinates for consistent 2D plotting
loc: '25.0330,121.5654',
latitude: 25.0330,
longitude: 121.5654
};
} else if (country === 'JP') {
return {
...geo,
city: 'Tokyo',
country: 'JP',
country_name: 'Japan',
// Force Tokyo coordinates for consistent 2D plotting
loc: '35.6895,139.6917',
latitude: 35.6895,
longitude: 139.6917
city: `China, ${geo.country.toUpperCase()}`,
country_name: 'China'
};
}
return geo;
}
async function getLocation(target) {
// Normalize target (strip port if present, handle IPv6 brackets)
let cleanTarget = target;
if (cleanTarget.startsWith('[')) {
const closingBracket = cleanTarget.indexOf(']');
if (closingBracket !== -1) {
cleanTarget = cleanTarget.substring(1, closingBracket);
}
} else {
const parts = cleanTarget.split(':');
if (parts.length === 2) {
cleanTarget = parts[0];
}
}
// Normalize target (strip port if present)
const cleanTarget = target.split(':')[0];
// 1. Check if we already have this IP/Domain in DB (FASTEST)
try {
@@ -85,18 +58,7 @@ async function getLocation(target) {
// Secondary DB check with resolved IP
const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]);
if (rows.length > 0) {
const data = rows[0];
// Cache the domain mapping to avoid future DNS lookups
if (cleanTarget !== cleanIp) {
try {
await db.query(`
INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE last_updated = CURRENT_TIMESTAMP
`, [cleanTarget, data.country, data.country_name, data.region, data.city, data.latitude, data.longitude]);
} catch(e) {}
}
return normalizeGeo(data);
return normalizeGeo(rows[0]);
}
} catch (err) {
// Quiet DNS failure for tokens (legacy bug mitigation)
@@ -156,29 +118,6 @@ async function getLocation(target) {
locationData.longitude
]);
// Cache the domain target as well if it differs from the resolved IP
if (cleanTarget !== cleanIp) {
await db.query(`
INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
country = VALUES(country),
country_name = VALUES(country_name),
region = VALUES(region),
city = VALUES(city),
latitude = VALUES(latitude),
longitude = VALUES(longitude)
`, [
cleanTarget,
locationData.country,
locationData.country_name,
locationData.region,
locationData.city,
locationData.latitude,
locationData.longitude
]);
}
return locationData;
}
} catch (err) {

View File

@@ -1,13 +1,13 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const db = require('./db');
const prometheusService = require('./prometheus-service');
const cache = require('./cache');
const geoService = require('./geo-service');
const latencyService = require('./latency-service');
const db_migrate = require('./db-schema-check');
const checkAndFixDatabase = require('./db-schema-check');
const http = require('http');
const WebSocket = require('ws');
const net = require('net');
@@ -15,6 +15,14 @@ const net = require('net');
const app = express();
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const IS_SERVERLESS = [
process.env.SERVERLESS,
process.env.VERCEL,
process.env.AWS_LAMBDA_FUNCTION_NAME,
process.env.NETLIFY,
process.env.FUNCTION_TARGET,
process.env.K_SERVICE
].some(Boolean);
app.use(cors());
app.use(express.json());
@@ -22,15 +30,14 @@ const fs = require('fs');
const crypto = require('crypto');
let isDbInitialized = false;
let metricSyncTimer = null; // Background sync timer
let bootstrapPromise = null;
let backgroundServicesStarted = false;
const sessions = new Map(); // Fallback session store when Valkey is unavailable
const requestBuckets = new Map();
const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400;
const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000;
const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true';
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
const APP_SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex');
process.env.APP_SECRET = APP_SECRET;
const RATE_LIMITS = {
login: { windowMs: 15 * 60 * 1000, max: 8 },
setup: { windowMs: 10 * 60 * 1000, max: 20 }
@@ -147,17 +154,25 @@ function getPublicSiteSettings(settings = {}) {
? (settings.require_login_for_server_details ? 1 : 0)
: 1,
icp_filing: settings.icp_filing || null,
ps_filing: settings.ps_filing || null,
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',
custom_metrics: settings.custom_metrics || [],
cdn_url: settings.cdn_url || null,
prometheus_cache_ttl: settings.prometheus_cache_ttl !== undefined ? parseInt(settings.prometheus_cache_ttl) : 30
ps_filing: settings.ps_filing || null
};
}
function getRuntimeConfig() {
return {
serverless: IS_SERVERLESS,
realtimeMode: IS_SERVERLESS ? 'polling' : 'websocket'
};
}
function hasDatabaseConfig() {
return Boolean(
process.env.MYSQL_HOST &&
process.env.MYSQL_USER &&
process.env.MYSQL_DATABASE
);
}
async function getSiteSettingsRow() {
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
return rows.length > 0 ? rows[0] : {};
@@ -166,18 +181,6 @@ async function getSiteSettingsRow() {
async function requireServerDetailsAccess(req, res, next) {
try {
const settings = await getSiteSettingsRow();
req.siteSettings = settings; // Store for later use (e.g. IP stripping)
// 1. Mandatory source validation for detail access
const sourceName = req.query.source;
if (sourceName) {
const [sources] = await db.query('SELECT is_detail_source FROM prometheus_sources WHERE name = ?', [sourceName]);
if (sources.length === 0 || !sources[0].is_detail_source) {
return res.status(403).json({ error: '该数据源已禁用详情查看权限' });
}
}
// 2. Global login requirement check
const requiresLogin = settings.require_login_for_server_details !== undefined
? !!settings.require_login_for_server_details
: true;
@@ -189,7 +192,7 @@ async function requireServerDetailsAccess(req, res, next) {
return requireAuth(req, res, next);
} catch (err) {
console.error('Server details access check failed:', err);
return res.status(500).json({ error: '权限验证失败' });
return res.status(500).json({ error: 'Failed to verify detail access' });
}
}
@@ -320,107 +323,50 @@ function getCookie(req, name) {
return matches ? decodeURIComponent(matches[1]) : undefined;
}
/**
* Database Initialization Check
*/
async function checkDb() {
try {
const fs = require('fs');
// In Docker or high-level environments, .env might not exist but process.env is set
const hasConfig = process.env.MYSQL_HOST || fs.existsSync(path.join(__dirname, '..', '.env'));
if (!hasConfig) {
if (!hasDatabaseConfig()) {
isDbInitialized = false;
return;
}
// Check for essential tables
const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'");
const [rows2] = await db.query("SHOW TABLES LIKE 'site_settings'");
isDbInitialized = rows.length > 0 && rows2.length > 0;
isDbInitialized = rows.length > 0;
} catch (err) {
isDbInitialized = false;
}
}
checkDb().then(() => {
if (isDbInitialized) {
startMetricSync();
checkDb();
async function bootstrapServices({ enableBackgroundTasks = !IS_SERVERLESS } = {}) {
if (!bootstrapPromise) {
bootstrapPromise = (async () => {
await checkAndFixDatabase();
await checkDb();
})().catch((err) => {
bootstrapPromise = null;
throw err;
});
}
await bootstrapPromise;
if (enableBackgroundTasks && !backgroundServicesStarted) {
latencyService.start();
backgroundServicesStarted = true;
}
}
app.use(async (req, res, next) => {
try {
await bootstrapServices({ enableBackgroundTasks: !IS_SERVERLESS });
next();
} catch (err) {
console.error('Service bootstrap failed:', err);
res.status(500).json({ error: 'Service initialization failed' });
}
});
/**
* Background Metric Synchronization Task
*/
async function startMetricSync() {
if (metricSyncTimer) {
clearInterval(metricSyncTimer);
metricSyncTimer = null;
}
try {
const [rows] = await db.query('SELECT prometheus_cache_ttl FROM site_settings WHERE id = 1');
const ttl = rows.length > 0 ? (parseInt(rows[0].prometheus_cache_ttl) || 0) : 30;
if (ttl <= 0) {
console.log('[MetricSync] Disabled (TTL=0)');
return;
}
console.log(`[MetricSync] Started with interval: ${ttl}s`);
// Immediate first run
runSync();
metricSyncTimer = setInterval(runSync, ttl * 1000);
} catch (err) {
console.error('[MetricSync] Failed to start:', err);
}
}
async function runSync() {
try {
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE type != "blackbox"');
if (sources.length === 0) return;
console.log(`[MetricSync] Syncing ${sources.length} sources at ${new Date().toLocaleTimeString()}`);
// 1. Sync individual source metrics (Overview & Detail)
await Promise.all(sources.map(async (source) => {
try {
const metrics = await prometheusService.getOverviewMetrics(source.url, source.name);
const enrichedMetrics = {
...metrics,
sourceName: source.name,
isOverview: !!source.is_overview_source,
isDetail: !!source.is_detail_source
};
const cacheKey = `source_metrics:${source.url}:${source.name}`;
await cache.set(cacheKey, enrichedMetrics, 86400); // Store for 24h, will be overwritten
} catch (err) {
console.error(`[MetricSync] Error syncing ${source.name}:`, err.message);
}
}));
// 2. Sync Network History (Optional but good for "real-time" feel)
const [historySources] = await db.query('SELECT * FROM prometheus_sources WHERE is_overview_source = 1 AND type != "blackbox"');
if (historySources.length > 0) {
const histories = await Promise.all(historySources.map(source =>
prometheusService.getNetworkHistory(source.url).catch(() => null)
));
const validHistories = histories.filter(h => h !== null);
if (validHistories.length > 0) {
const merged = prometheusService.mergeNetworkHistories(validHistories);
await cache.set('network_history_all', merged, 86400);
}
}
} catch (err) {
console.error('[MetricSync] Sync loop error:', err);
}
}
// --- Health API ---
app.get('/health', async (req, res) => {
try {
@@ -652,11 +598,6 @@ app.post('/api/setup/init', ensureSetupAccess, async (req, res) => {
latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
network_data_sources TEXT,
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
`);
@@ -665,7 +606,11 @@ app.post('/api/setup/init', ensureSetupAccess, async (req, res) => {
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0, 'tx')
`);
// Note: checkAndFixDatabase (called later in this route) will handle column migrations correctly and compatibly.
// Ensure the first-run schema matches the runtime expectations without requiring a restart migration.
await connection.query("ALTER TABLE prometheus_sources ADD COLUMN IF NOT EXISTS is_server_source TINYINT(1) DEFAULT 1 AFTER description");
await connection.query("ALTER TABLE prometheus_sources ADD COLUMN IF NOT EXISTS type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source");
await connection.query("ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS show_page_name TINYINT(1) DEFAULT 1 AFTER page_name");
await connection.query("ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type");
await connection.query(`
CREATE TABLE IF NOT EXISTS latency_routes (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -709,7 +654,6 @@ COOKIE_SECURE=${process.env.COOKIE_SECURE || 'false'}
SESSION_TTL_SECONDS=${process.env.SESSION_TTL_SECONDS || SESSION_TTL_SECONDS}
PASSWORD_ITERATIONS=${process.env.PASSWORD_ITERATIONS || PASSWORD_ITERATIONS}
ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'}
APP_SECRET=${process.env.APP_SECRET || APP_SECRET}
`;
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
@@ -727,9 +671,6 @@ APP_SECRET=${process.env.APP_SECRET || APP_SECRET}
db.initPool();
cache.init();
// Run the migration/centralized schema tool to create/fix all tables
await db_migrate();
isDbInitialized = true;
res.json({ success: true, message: 'Initialization complete' });
} catch (err) {
@@ -839,8 +780,7 @@ const serveIndex = async (req, res) => {
latency_dest: null,
latency_target: null,
icp_filing: null,
ps_filing: null,
network_data_sources: null
ps_filing: null
};
if (isDbInitialized) {
@@ -854,19 +794,12 @@ const serveIndex = async (req, res) => {
// Inject settings
const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings));
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
const runtimeJson = escapeJsonForInlineScript(getRuntimeConfig());
const injection = `<script>window.SITE_SETTINGS = ${settingsJson}; window.APP_RUNTIME = ${runtimeJson};</script>`;
// Replace <head> with <head> + injection
html = html.replace('<head>', '<head>' + injection);
// Apply CDN URL if set
if (settings.cdn_url) {
const cdnBase = settings.cdn_url.replace(/\/+$/, '');
// Replace relative paths for css, js, fonts, vendor, images
// Matches href="/css/...", src="/js/...", etc.
html = html.replace(/(href|src)="\/+(css|js|fonts|vendor|images|assets)\//g, `$1="${cdnBase}/$2/`);
}
res.send(html);
} catch (err) {
console.error('Error serving index:', err);
@@ -894,8 +827,7 @@ app.get('/api/sources', requireAuth, async (req, res) => {
const res = await fetch(`${source.url.replace(/\/+$/, '')}/metrics`, { timeout: 3000 }).catch(() => null);
response = (res && res.ok) ? 'Blackbox Exporter Ready' : 'Connection Error';
} else {
// Use a shorter timeout for list view to prevent blocking UI
response = await prometheusService.testConnection(source.url, 2500);
response = await prometheusService.testConnection(source.url);
}
return { ...source, status: 'online', version: response };
} catch (e) {
@@ -918,16 +850,8 @@ app.post('/api/sources', requireAuth, async (req, res) => {
if (!/^https?:\/\//i.test(url)) url = 'http://' + url;
try {
const [result] = await db.query(
'INSERT INTO prometheus_sources (name, url, description, is_server_source, is_overview_source, is_detail_source, type) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
name,
url,
description || '',
is_server_source === undefined ? 1 : (is_server_source ? 1 : 0),
req.body.is_overview_source === undefined ? 1 : (req.body.is_overview_source ? 1 : 0),
req.body.is_detail_source === undefined ? 1 : (req.body.is_detail_source ? 1 : 0),
type || 'prometheus'
]
'INSERT INTO prometheus_sources (name, url, description, is_server_source, type) VALUES (?, ?, ?, ?, ?)',
[name, url, description || '', is_server_source === undefined ? 1 : (is_server_source ? 1 : 0), type || 'prometheus']
);
const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]);
@@ -947,17 +871,8 @@ app.put('/api/sources/:id', requireAuth, async (req, res) => {
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
try {
await db.query(
'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ?, is_overview_source = ?, is_detail_source = ?, type = ? WHERE id = ?',
[
name,
url,
description || '',
is_server_source ? 1 : 0,
req.body.is_overview_source ? 1 : 0,
req.body.is_detail_source ? 1 : 0,
type || 'prometheus',
req.params.id
]
'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ?, type = ? WHERE id = ?',
[name, url, description || '', is_server_source ? 1 : 0, type || 'prometheus', req.params.id]
);
// Clear network history cache
await cache.del('network_history_all');
@@ -1012,6 +927,25 @@ app.get('/api/settings', async (req, res) => {
return res.json(getPublicSiteSettings());
}
return res.json(getPublicSiteSettings(rows[0]));
if (rows.length === 0) {
return res.json({
page_name: '数据可视化展示大屏',
show_page_name: 1,
title: '数据可视化展示大屏',
logo_url: null,
logo_url_dark: null,
favicon_url: null,
show_95_bandwidth: 0,
p95_type: 'tx',
blackbox_source_id: null,
latency_source: null,
latency_dest: null,
latency_target: null,
icp_filing: null,
ps_filing: null
});
}
res.json(rows[0]);
} catch (err) {
console.error('Error fetching settings:', err);
res.status(500).json({ error: 'Failed to fetch settings' });
@@ -1029,8 +963,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, show_server_ip, ip_metric_name, ip_label_name, custom_metrics,
cdn_url, prometheus_cache_ttl
icp_filing, ps_filing
} = req.body;
// 3. Prepare parameters, prioritizing body but falling back to current
@@ -1047,30 +980,22 @@ app.post('/api/settings', requireAuth, async (req, res) => {
require_login_for_server_details: require_login_for_server_details !== undefined
? (require_login_for_server_details ? 1 : 0)
: (current.require_login_for_server_details !== undefined ? current.require_login_for_server_details : 1),
blackbox_source_id: current.blackbox_source_id || null,
latency_source: current.latency_source || null,
latency_dest: current.latency_dest || null,
latency_target: current.latency_target || null,
blackbox_source_id: current.blackbox_source_id || null, // UI doesn't send this
latency_source: current.latency_source || null, // UI doesn't send this
latency_dest: current.latency_dest || null, // UI doesn't send this
latency_target: current.latency_target || null, // UI doesn't send this
icp_filing: icp_filing !== undefined ? icp_filing : (current.icp_filing || null),
ps_filing: ps_filing !== undefined ? ps_filing : (current.ps_filing || 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'),
custom_metrics: custom_metrics !== undefined ? JSON.stringify(custom_metrics) : (current.custom_metrics || '[]'),
cdn_url: cdn_url !== undefined ? cdn_url : (current.cdn_url || null),
prometheus_cache_ttl: prometheus_cache_ttl !== undefined
? Math.min(86400, Math.max(0, parseInt(prometheus_cache_ttl) || 0))
: (current.prometheus_cache_ttl !== undefined ? current.prometheus_cache_ttl : 30)
ps_filing: ps_filing !== undefined ? ps_filing : (current.ps_filing || null)
};
await db.query(`
INSERT INTO site_settings (
// 4. Update database
await db.query(
`INSERT INTO site_settings (
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, show_server_ip, ip_metric_name, ip_label_name,
custom_metrics, cdn_url, prometheus_cache_ttl
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
icp_filing, ps_filing
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
page_name = VALUES(page_name),
show_page_name = VALUES(show_page_name),
@@ -1087,25 +1012,16 @@ app.post('/api/settings', requireAuth, async (req, res) => {
latency_dest = VALUES(latency_dest),
latency_target = VALUES(latency_target),
icp_filing = VALUES(icp_filing),
ps_filing = VALUES(ps_filing),
show_server_ip = VALUES(show_server_ip),
ip_metric_name = VALUES(ip_metric_name),
ip_label_name = VALUES(ip_label_name),
custom_metrics = VALUES(custom_metrics),
cdn_url = VALUES(cdn_url),
prometheus_cache_ttl = VALUES(prometheus_cache_ttl)`,
ps_filing = VALUES(ps_filing)`,
[
settings.page_name, settings.show_page_name, settings.title, settings.logo_url, settings.logo_url_dark, settings.favicon_url,
settings.default_theme, settings.show_95_bandwidth, settings.p95_type, settings.require_login_for_server_details,
settings.blackbox_source_id, settings.latency_source, settings.latency_dest, settings.latency_target,
settings.icp_filing, settings.ps_filing, settings.show_server_ip,
settings.ip_metric_name, settings.ip_label_name, settings.custom_metrics,
settings.cdn_url, settings.prometheus_cache_ttl
settings.icp_filing, settings.ps_filing
]
);
startMetricSync();
res.json({ success: true, settings: getPublicSiteSettings(settings) });
res.json({ success: true });
} catch (err) {
console.error('Error updating settings:', err);
res.status(500).json({ error: 'Failed to update settings' });
@@ -1116,9 +1032,7 @@ app.post('/api/settings', requireAuth, async (req, res) => {
// Reusable function to get overview metrics
async function getOverview(force = false) {
// Fetch sources: overview OR detail
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE (is_overview_source = 1 OR is_detail_source = 1) AND type != "blackbox"');
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
if (sources.length === 0) {
return {
totalServers: 0,
@@ -1132,76 +1046,53 @@ async function getOverview(force = false) {
};
}
// If force is true, trigger an immediate sync run
if (force) {
await runSync();
}
// ONLY read from cache.
const allMetrics = await Promise.all(sources.map(async (source) => {
const cacheKey = `source_metrics:${source.url}:${source.name}`;
const cached = await cache.get(cacheKey);
return cached || null;
if (force) {
await cache.del(cacheKey);
} else {
const cached = await cache.get(cacheKey);
if (cached) return cached;
}
try {
const metrics = await prometheusService.getOverviewMetrics(source.url, source.name);
// Don't set cache here if we want real-time WS push to be fresh,
// but keeping it for REST API performance is fine.
await cache.set(cacheKey, metrics, 15); // Cache for 15s
return metrics;
} catch (err) {
console.error(`Error fetching metrics from ${source.name}:`, err.message);
return null;
}
}));
const validMetrics = allMetrics.filter(m => m !== null);
// Use Maps to deduplicate servers across multiple Prometheus sources
const uniqueOverviewServers = new Map();
const uniqueDetailServers = new Map();
for (const m of validMetrics) {
if (m.isOverview) {
for (const s of m.servers) {
// originalInstance is the true IP/host before token masking
const key = `${s.originalInstance}::${s.job}`;
if (!uniqueOverviewServers.has(key)) {
uniqueOverviewServers.set(key, s);
} else if (s.up && !uniqueOverviewServers.get(key).up) {
// Prefer 'up' status if duplicate
uniqueOverviewServers.set(key, s);
}
}
}
if (m.isDetail) {
for (const s of m.servers) {
const key = `${s.originalInstance}::${s.job}`;
if (!uniqueDetailServers.has(key)) {
uniqueDetailServers.set(key, s);
} else if (s.up && !uniqueDetailServers.get(key).up) {
uniqueDetailServers.set(key, s);
}
}
}
}
const allOverviewServers = Array.from(uniqueOverviewServers.values());
const allDetailServers = Array.from(uniqueDetailServers.values());
// Aggregate across unique deduplicated servers
let totalServers = allOverviewServers.length;
// Aggregate across all sources
let totalServers = 0;
let activeServers = 0;
let cpuUsed = 0, cpuTotal = 0;
let memUsed = 0, memTotal = 0;
let diskUsed = 0, diskTotal = 0;
let netRx = 0, netTx = 0;
let traffic24hRx = 0, traffic24hTx = 0;
let allServers = [];
for (const inst of allOverviewServers) {
if (inst.up) {
activeServers++;
cpuUsed += (inst.cpuPercent / 100) * inst.cpuCores;
cpuTotal += inst.cpuCores;
memUsed += inst.memUsed;
memTotal += inst.memTotal;
diskUsed += inst.diskUsed;
diskTotal += inst.diskTotal;
netRx += inst.netRx || 0;
netTx += inst.netTx || 0;
traffic24hRx += inst.traffic24hRx || 0;
traffic24hTx += inst.traffic24hTx || 0;
}
for (const m of validMetrics) {
totalServers += m.totalServers;
activeServers += (m.activeServers !== undefined ? m.activeServers : m.totalServers);
cpuUsed += m.cpu.used;
cpuTotal += m.cpu.total;
memUsed += m.memory.used;
memTotal += m.memory.total;
diskUsed += m.disk.used;
diskTotal += m.disk.total;
netRx += m.network.rx;
netTx += m.network.tx;
traffic24hRx += m.traffic24h.rx;
traffic24hTx += m.traffic24h.tx;
allServers = allServers.concat(m.servers);
}
const overview = {
@@ -1232,27 +1123,13 @@ async function getOverview(force = false) {
tx: traffic24hTx,
total: traffic24hRx + traffic24hTx
},
servers: allDetailServers
servers: allServers
};
// --- Add Geo Information to Servers ---
const geoServers = await Promise.all(overview.servers.map(async (server) => {
const realInstance = server.originalInstance || await prometheusService.resolveToken(server.instance);
// Helper to get host from instance (handles IPv6 with brackets, IPv4:port, etc.)
let cleanIp = realInstance;
if (cleanIp.startsWith('[')) {
const closingBracket = cleanIp.indexOf(']');
if (closingBracket !== -1) {
cleanIp = cleanIp.substring(1, closingBracket);
}
} else {
const parts = cleanIp.split(':');
// If exactly one colon, it's likely IPv4:port or host:port
if (parts.length === 2) {
cleanIp = parts[0];
}
// If more than 1 colon and no brackets, it's an IPv6 without port - keep as is
}
const realInstance = server.originalInstance || prometheusService.resolveToken(server.instance);
const cleanIp = realInstance.split(':')[0];
let geoData = null;
try {
@@ -1282,6 +1159,37 @@ async function getOverview(force = false) {
return overview;
}
async function getLatencyResults() {
const [routes] = await db.query(`
SELECT r.*, s.url, s.type as source_type
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
`);
if (routes.length === 0) {
return [];
}
return Promise.all(routes.map(async (route) => {
let latency = await cache.get(`latency:route:${route.id}`);
if (latency === null) {
if (route.source_type === 'prometheus') {
latency = await prometheusService.getLatency(route.url, route.latency_target);
} else if (route.source_type === 'blackbox') {
latency = await latencyService.resolveLatencyForRoute(route);
}
}
return {
id: route.id,
source: route.latency_source,
dest: route.latency_dest,
latency
};
}));
}
// Get all aggregated metrics from all Prometheus sources
app.get('/api/metrics/overview', async (req, res) => {
try {
@@ -1302,13 +1210,31 @@ app.get('/api/metrics/network-history', async (req, res) => {
if (force) {
await cache.del(cacheKey);
} else {
const cached = await cache.get(cacheKey);
if (cached) return res.json(cached);
}
const cached = await cache.get(cacheKey);
if (cached) return res.json(cached);
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
if (sources.length === 0) {
return res.json({ timestamps: [], rx: [], tx: [] });
}
// Fallback: If no cache, return empty instead of triggering Prometheus
return res.json({ timestamps: [], rx: [], tx: [] });
const histories = await Promise.all(sources.map(source =>
prometheusService.getNetworkHistory(source.url).catch(err => {
console.error(`Error fetching network history from ${source.name}:`, err.message);
return null;
})
));
const validHistories = histories.filter(h => h !== null);
if (validHistories.length === 0) {
return res.json({ timestamps: [], rx: [], tx: [] });
}
const merged = prometheusService.mergeNetworkHistories(validHistories);
await cache.set(cacheKey, merged, 300); // Cache for 5 minutes
res.json(merged);
} catch (err) {
console.error('Error fetching network history history:', err);
res.status(500).json({ error: 'Failed to fetch network history history' });
@@ -1318,7 +1244,7 @@ app.get('/api/metrics/network-history', async (req, res) => {
// Get CPU usage history for sparklines
app.get('/api/metrics/cpu-history', async (req, res) => {
try {
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_overview_source = 1 AND type != "blackbox"');
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
if (sources.length === 0) {
return res.json({ timestamps: [], values: [] });
}
@@ -1359,15 +1285,8 @@ app.get('/api/metrics/server-details', requireServerDetailsAccess, async (req, r
}
const sourceUrl = rows[0].url;
// Fetch detailed metrics with custom metric configuration if present
const details = await prometheusService.getServerDetails(sourceUrl, instance, job, req.siteSettings);
// Dynamic field removal based on security settings: PHYSICAL DATA STRIPPING
if (!req.siteSettings || !req.siteSettings.show_server_ip) {
delete details.ipv4;
delete details.ipv6;
}
// Fetch detailed metrics
const details = await prometheusService.getServerDetails(sourceUrl, instance, job);
res.json(details);
} catch (err) {
console.error(`Error fetching server details for ${instance}:`, err.message);
@@ -1382,7 +1301,7 @@ app.get('/api/metrics/server-history', requireServerDetailsAccess, async (req, r
if (!instance || !job || !source || !metric) {
return res.status(400).json({ error: 'instance, job, source, and metric are required' });
}
try {
try {
const [rows] = await db.query('SELECT url FROM prometheus_sources WHERE name = ?', [source]);
if (rows.length === 0) return res.status(404).json({ error: 'Source not found' });
@@ -1401,6 +1320,30 @@ app.get('/api/metrics/server-history', requireServerDetailsAccess, async (req, r
res.status(500).json({ error: err.message });
}
});
app.get('/api/realtime/overview', async (req, res) => {
try {
const force = req.query.force === 'true';
const [overview, latencies] = await Promise.all([
getOverview(force),
getLatencyResults()
]);
res.json({
...overview,
latencies
});
} catch (err) {
console.error('Error fetching realtime overview:', err);
res.status(500).json({ error: 'Failed to fetch realtime overview' });
}
});
// SPA fallback
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.includes('.')) return next();
serveIndex(req, res);
});
// ==================== Latency Routes CRUD ====================
@@ -1453,33 +1396,7 @@ app.put('/api/latency-routes/:id', requireAuth, async (req, res) => {
// ==================== Metrics Latency ====================
app.get('/api/metrics/latency', async (req, res) => {
const [routes] = await db.query(`
SELECT r.*, s.url, s.type as source_type
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
`);
if (routes.length === 0) {
return res.json({ routes: [] });
}
const results = await Promise.all(routes.map(async (route) => {
// Try to get from Valkey first (filled by background latencyService)
let latency = await cache.get(`latency:route:${route.id}`);
// Fallback if not in cache (only for prometheus sources, blackbox sources rely on the background service)
if (latency === null && route.source_type === 'prometheus') {
latency = await prometheusService.getLatency(route.url, route.latency_target);
}
return {
id: route.id,
source: route.latency_source,
dest: route.latency_dest,
latency: latency
};
}));
try {
const results = await getLatencyResults();
res.json({ routes: results });
} catch (err) {
@@ -1489,13 +1406,12 @@ app.get('/api/metrics/latency', async (req, res) => {
});
// ==================== WebSocket Server ====================
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
const server = IS_SERVERLESS ? null : http.createServer(app);
const wss = IS_SERVERLESS ? null : new WebSocket.Server({ server });
let cachedLatencyRoutes = null;
let lastRoutesUpdate = 0;
let isBroadcastRunning = false;
function broadcast(data) {
if (!wss) return;
const message = JSON.stringify(data);
wss.clients.forEach(client => {
@@ -1504,36 +1420,14 @@ function broadcast(data) {
}
});
}
// Broadcast loop
async function broadcastMetrics() {
if (IS_SERVERLESS || !wss) return;
if (isBroadcastRunning) return;
isBroadcastRunning = true;
try {
// Refresh routes list every 60 seconds or if it hasn't been fetched yet
const now = Date.now();
if (!cachedLatencyRoutes || now - lastRoutesUpdate > 60000) {
const [routes] = await db.query(`
SELECT r.*, s.url, s.type as source_type
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
`);
cachedLatencyRoutes = routes;
lastRoutesUpdate = now;
}
const latencyResults = await Promise.all(cachedLatencyRoutes.map(async (route) => {
let latency = await cache.get(`latency:route:${route.id}`);
if (latency === null && route.source_type === 'prometheus') {
latency = await prometheusService.getLatency(route.url, route.latency_target);
}
return {
id: route.id,
source: route.latency_source,
dest: route.latency_dest,
latency: latency
};
}));
const overview = await getOverview();
const latencyResults = await getLatencyResults();
broadcast({
@@ -1553,55 +1447,11 @@ async function broadcastMetrics() {
// Start server and services
async function start() {
try {
// 1. Initial check
await checkDb();
// 2. Automated repair/migration
try {
const dbFixed = await db_migrate();
if (dbFixed) {
// Refresh state after fix
await checkDb();
if (isDbInitialized) {
console.log(' ✅ Database integrity verified and initialized');
}
}
} catch (dbErr) {
console.error('❌ Critical database initialization error:', dbErr.message);
// If we have an .env but can't connect, this is a fatal config error
if (fs.existsSync(path.join(__dirname, '..', '.env'))) {
console.error(' Please check your MYSQL settings in .env or run setup wizard.');
// Don't exit, allow the user to reach the init/setup page to fix configurations
}
}
// Start services
latencyService.start();
console.log('🔧 Initializing services...');
await bootstrapServices({ enableBackgroundTasks: true });
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
// Periodic cleanup of sessions Map to prevent memory growth
setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of sessions.entries()) {
if (session.expiresAt && session.expiresAt <= now) {
sessions.delete(sessionId);
}
}
}, 300000); // Once every 5 minutes
server.listen(PORT, HOST, () => {
console.log(`\n 🚀 Data Visualization Display Wall (WebSocket Enabled)`);
@@ -1613,12 +1463,20 @@ async function start() {
process.exit(1);
}
}
process.on('unhandledRejection', (reason, promise) => {
console.error('[System] Unhandled Rejection at:', promise, 'reason:', reason);
});
if (require.main === module) {
if (IS_SERVERLESS) {
bootstrapServices({ enableBackgroundTasks: false }).catch((err) => {
console.error('Service bootstrap failed:', err.message);
process.exit(1);
});
} else {
start();
}
}
process.on('uncaughtException', (err) => {
console.error('[System] Uncaught Exception:', err);
});
start();
module.exports = {
app,
start,
bootstrapServices,
isServerless: IS_SERVERLESS

View File

@@ -1,40 +1,102 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
/**
* Database Initialization Script
* Run: npm run init-db
* Creates the required MySQL database and tables.
*/
require('dotenv').config();
const mysql = require('mysql2/promise');
const db_migrate = require('./db-schema-check');
const db = require('./db');
async function initDatabase() {
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';
// 1. Create connection without database selected to create the DB itself
const connection = await mysql.createConnection({
host,
port,
user,
password
host: process.env.MYSQL_HOST || 'localhost',
port: parseInt(process.env.MYSQL_PORT) || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || ''
});
console.log('🔧 Initializing database environment...\n');
const dbName = process.env.MYSQL_DATABASE || 'display_wall';
console.log('🔧 Initializing database...\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();
// 2. Re-initialize the standard pool so it can see the new DB
db.initPool();
await connection.query(`USE \`${dbName}\``);
// 3. Use the centralized schema tool to create/fix all tables
console.log(' 📦 Initializing tables using schema-check tool...');
await db_migrate();
console.log(' ✅ Tables and columns ready');
// 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');
console.log('\n🎉 Database initialization complete!\n');
await connection.end();
}
initDatabase().catch(err => {

View File

@@ -4,6 +4,115 @@ const db = require('./db');
const POLL_INTERVAL = 10000; // 10 seconds
async function resolveBlackboxLatency(route) {
// Blackbox exporter probe URL
// We assume ICMP module for now. If target is a URL, maybe use http_2xx
let module = 'icmp';
const target = route.latency_target;
if (target.startsWith('http://') || target.startsWith('https://')) {
module = 'http_2xx';
}
const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`;
const response = await axios.get(probeUrl, {
timeout: 5000,
responseType: 'text',
validateStatus: false
});
if (typeof response.data !== 'string') {
throw new Error('Response data is not a string');
}
const lines = response.data.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
// 1. Check if the probe was successful
let isProbeSuccess = false;
for (const line of lines) {
if (/^probe_success(\{.*\})?\s+1/.test(line)) {
isProbeSuccess = true;
break;
}
}
// 2. Extract latency from priority metrics
const targetMetrics = [
'probe_icmp_duration_seconds',
'probe_http_duration_seconds',
'probe_duration_seconds'
];
let foundLatency = null;
for (const metricName of targetMetrics) {
let bestLine = null;
// First pass: look for phase="rtt" which is the most accurate "ping"
for (const line of lines) {
if (line.startsWith(metricName) && line.includes('phase="rtt"')) {
bestLine = line;
break;
}
}
// Second pass: if no rtt phase, look for a line without phases (legacy format) or just the first line
if (!bestLine) {
for (const line of lines) {
if (line.startsWith(metricName)) {
// Prefer lines without {} if possible, otherwise take the first one
if (!line.includes('{')) {
bestLine = line;
break;
}
if (!bestLine) bestLine = line;
}
}
}
if (bestLine) {
// Regex to capture the number, including scientific notation
const regex = new RegExp(`^${metricName}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)`);
const match = bestLine.match(regex);
if (match) {
const val = parseFloat(match[1]);
if (!isNaN(val)) {
foundLatency = val * 1000; // convert to ms
break;
}
}
}
}
// 3. Final decision
// If it's a success, use found latency. If success=0 or missing, handle carefully.
if (isProbeSuccess && foundLatency !== null) {
return foundLatency;
}
// If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
return null;
}
async function resolveLatencyForRoute(route) {
try {
if (route.source_type === 'blackbox' || route.type === 'blackbox') {
const latency = await resolveBlackboxLatency(route);
if (route.id !== undefined) {
await cache.set(`latency:route:${route.id}`, latency, 60);
}
return latency;
}
return null;
} catch (err) {
if (route.id !== undefined) {
await cache.set(`latency:route:${route.id}`, null, 60);
}
return null;
}
}
async function pollLatency() {
try {
const [routes] = await db.query(`
@@ -18,99 +127,7 @@ async function pollLatency() {
// Poll each route
await Promise.allSettled(routes.map(async (route) => {
try {
// Blackbox exporter probe URL
// We assume ICMP module for now. If target is a URL, maybe use http_2xx
let module = 'icmp';
let target = route.latency_target;
if (target.startsWith('http://') || target.startsWith('https://')) {
module = 'http_2xx';
}
const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`;
const startTime = Date.now();
const response = await axios.get(probeUrl, {
timeout: 5000,
responseType: 'text',
validateStatus: false
});
if (typeof response.data !== 'string') {
throw new Error('Response data is not a string');
}
const lines = response.data.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
// 1. Check if the probe was successful
let isProbeSuccess = false;
for (const line of lines) {
if (/^probe_success(\{.*\})?\s+1/.test(line)) {
isProbeSuccess = true;
break;
}
}
// 2. Extract latency from priority metrics
const targetMetrics = [
'probe_icmp_duration_seconds',
'probe_http_duration_seconds',
'probe_duration_seconds'
];
let foundLatency = null;
for (const metricName of targetMetrics) {
let bestLine = null;
// First pass: look for phase="rtt" which is the most accurate "ping"
for (const line of lines) {
if (line.startsWith(metricName) && line.includes('phase="rtt"')) {
bestLine = line;
break;
}
}
// Second pass: if no rtt phase, look for a line without phases (legacy format) or just the first line
if (!bestLine) {
for (const line of lines) {
if (line.startsWith(metricName)) {
// Prefer lines without {} if possible, otherwise take the first one
if (!line.includes('{')) {
bestLine = line;
break;
}
if (!bestLine) bestLine = line;
}
}
}
if (bestLine) {
// Regex to capture the number, including scientific notation
const regex = new RegExp(`^${metricName}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)`);
const match = bestLine.match(regex);
if (match) {
const val = parseFloat(match[1]);
if (!isNaN(val)) {
foundLatency = val * 1000; // convert to ms
break;
}
}
}
}
// 3. Final decision
// If it's a success, use found latency. If success=0 or missing, handle carefully.
let latency;
if (isProbeSuccess && foundLatency !== null) {
latency = foundLatency;
} else {
// If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
latency = null;
}
// Save to Valkey
await cache.set(`latency:route:${route.id}`, latency, 60);
await resolveLatencyForRoute({ ...route, source_type: 'blackbox' });
} catch (err) {
await cache.set(`latency:route:${route.id}`, null, 60);
}
@@ -130,5 +147,7 @@ function start() {
}
module.exports = {
pollLatency,
resolveLatencyForRoute,
start
};

View File

@@ -1,46 +1,22 @@
const axios = require('axios');
const http = require('http');
const https = require('https');
const cache = require('./cache');
const crypto = require('crypto');
const QUERY_TIMEOUT = 10000;
function getCacheKey(type, baseUrl, expr, extra = '') {
return `prom_${type}:${crypto.createHash('md5').update(`${baseUrl}:${expr}:${extra}`).digest('hex')}`;
}
// Reusable agents to handle potential redirect issues and protocol mismatches
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, lastSeen }
function getSecret() {
// Use the env variable populated by index.js initialization
return process.env.APP_SECRET || 'fallback-secret-for-safety';
}
// 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
const serverIdMap = new Map(); // token -> { instance, job, source }
const SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex');
function getServerToken(instance, job, source) {
const hash = crypto.createHmac('sha256', getSecret())
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;
}
@@ -72,12 +48,12 @@ function createClient(baseUrl) {
/**
* Test Prometheus connection
*/
async function testConnection(url, customTimeout = null) {
async function testConnection(url) {
const normalized = normalizeUrl(url);
try {
// Using native fetch to avoid follow-redirects/axios "protocol mismatch" issues in some Node environments
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), customTimeout || QUERY_TIMEOUT);
const timer = setTimeout(() => controller.abort(), QUERY_TIMEOUT);
// Node native fetch - handles http/https automatically
const res = await fetch(`${normalized}/api/v1/status/buildinfo`, {
@@ -212,11 +188,7 @@ async function getOverviewMetrics(url, sourceName) {
diskFreeResult,
netRxResult,
netTxResult,
netRx24hResult,
netTx24hResult,
targetsResult,
conntrackEntriesResult,
conntrackLimitResult
targetsResult
] = await Promise.all([
// CPU usage per instance: 1 - avg idle
query(url, '100 - (avg by (instance, job) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)').catch(() => []),
@@ -234,16 +206,8 @@ async function getOverviewMetrics(url, sourceName) {
query(url, 'sum by (instance, job) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
// Network transmit rate (bytes/sec)
query(url, 'sum by (instance, job) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
// 24h Network receive total (bytes)
query(url, 'sum by (instance, job) (increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
// 24h Network transmit total (bytes)
query(url, 'sum by (instance, job) (increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
// Targets status from /api/v1/targets
getTargets(url).catch(() => []),
// Conntrack entries
query(url, 'node_nf_conntrack_entries').catch(() => []),
// Conntrack limits
query(url, 'node_nf_conntrack_entries_limit').catch(() => [])
getTargets(url).catch(() => [])
]);
// Fetch 24h detailed traffic using the A*duration logic
@@ -258,10 +222,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, lastSeen: Date.now() });
// Also store in Valkey for resilience across restarts
cache.set(`server_token:${token}`, originalInstance, 86400).catch(()=>{});
serverIdMap.set(token, { instance: originalInstance, source: sourceName, job });
if (!instances.has(token)) {
instances.set(token, {
@@ -277,14 +238,9 @@ async function getOverviewMetrics(url, sourceName) {
diskUsed: 0,
netRx: 0,
netTx: 0,
traffic24hRx: 0,
traffic24hTx: 0,
conntrackEntries: 0,
conntrackLimit: 0,
up: false,
memPercent: 0,
diskPercent: 0,
conntrackPercent: 0
diskPercent: 0
});
}
const inst = instances.get(token);
@@ -350,26 +306,6 @@ async function getOverviewMetrics(url, sourceName) {
inst.netTx = parseFloat(r.value[1]) || 0;
}
// Parse 24h traffic
for (const r of netRx24hResult) {
const inst = getOrCreate(r.metric);
inst.traffic24hRx = parseFloat(r.value[1]) || 0;
}
for (const r of netTx24hResult) {
const inst = getOrCreate(r.metric);
inst.traffic24hTx = parseFloat(r.value[1]) || 0;
}
// Parse conntrack
for (const r of conntrackEntriesResult) {
const inst = getOrCreate(r.metric);
inst.conntrackEntries = parseFloat(r.value[1]) || 0;
}
for (const r of conntrackLimitResult) {
const inst = getOrCreate(r.metric);
inst.conntrackLimit = parseFloat(r.value[1]) || 0;
}
for (const inst of instances.values()) {
if (!inst.up && (inst.cpuPercent > 0 || inst.memTotal > 0)) {
inst.up = true;
@@ -377,7 +313,6 @@ async function getOverviewMetrics(url, sourceName) {
// Calculate percentages on backend
inst.memPercent = inst.memTotal > 0 ? (inst.memUsed / inst.memTotal * 100) : 0;
inst.diskPercent = inst.diskTotal > 0 ? (inst.diskUsed / inst.diskTotal * 100) : 0;
inst.conntrackPercent = inst.conntrackLimit > 0 ? (inst.conntrackEntries / inst.conntrackLimit * 100) : 0;
}
const allInstancesList = Array.from(instances.values());
@@ -456,23 +391,25 @@ function calculateTrafficFromHistory(values) {
}
/**
* Get total traffic for the past 24h using Prometheus increase() for stability and accuracy
* Get total traffic for the past 24h by fetching all points and integrating
*/
async function get24hTrafficSum(url) {
try {
const [rxResult, txResult] = await Promise.all([
query(url, 'sum(increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
query(url, 'sum(increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => [])
]);
const now = Math.floor(Date.now() / 1000);
const start = now - 86400;
const step = 60; // 1-minute points for calculation
const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0;
const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0;
const [rxResult, txResult] = await Promise.all([
queryRange(url, 'sum(rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))', start, now, step).catch(() => []),
queryRange(url, 'sum(rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))', start, now, step).catch(() => [])
]);
return { rx, tx };
} catch (err) {
console.error(`[Prometheus] get24hTrafficSum error:`, err.message);
return { rx: 0, tx: 0 };
}
const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
const txValues = txResult.length > 0 ? txResult[0].values : [];
return {
rx: calculateTrafficFromHistory(rxValues),
tx: calculateTrafficFromHistory(txValues)
};
}
/**
@@ -480,28 +417,34 @@ async function get24hTrafficSum(url) {
*/
async function get24hServerTrafficSum(url, instance, job) {
const node = resolveToken(instance);
const now = Math.floor(Date.now() / 1000);
const start = now - 86400;
const step = 60;
const rxExpr = `sum(increase(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[24h]))`;
const txExpr = `sum(increase(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[24h]))`;
const rxExpr = `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`;
const txExpr = `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`;
const [rxResult, txResult] = await Promise.all([
query(url, rxExpr).catch(() => []),
query(url, txExpr).catch(() => [])
queryRange(url, rxExpr, start, now, step).catch(() => []),
queryRange(url, txExpr, start, now, step).catch(() => [])
]);
const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0;
const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0;
const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
const txValues = txResult.length > 0 ? txResult[0].values : [];
return { rx, tx };
return {
rx: calculateTrafficFromHistory(rxValues),
tx: calculateTrafficFromHistory(txValues)
};
}
/**
* Get network traffic history (past 24h, 5-min intervals for chart)
*/
async function getNetworkHistory(url) {
const step = 300; // 5 minutes for better resolution on chart
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
const now = Math.floor(Date.now() / 1000);
const start = now - 86400; // 24h ago
const step = 300; // 5 minutes for better resolution on chart
const [rxResult, txResult] = await Promise.all([
queryRange(url,
@@ -553,9 +496,9 @@ function mergeNetworkHistories(histories) {
* Get CPU usage history (past 1h, 1-min intervals)
*/
async function getCpuHistory(url) {
const step = 60; // 1 minute
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
const now = Math.floor(Date.now() / 1000);
const start = now - 3600; // 1h ago
const step = 60; // 1 minute
const result = await queryRange(url,
'100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)',
@@ -590,22 +533,19 @@ function mergeCpuHistories(histories) {
}
async function resolveToken(token) {
function resolveToken(token) {
if (serverIdMap.has(token)) {
return serverIdMap.get(token).instance;
}
const cachedInstance = await cache.get(`server_token:${token}`);
if (cachedInstance) return cachedInstance;
return token;
}
/**
* Get detailed metrics for a specific server (node)
*/
async function getServerDetails(baseUrl, instance, job, settings = {}) {
async function getServerDetails(baseUrl, instance, job) {
const url = normalizeUrl(baseUrl);
const node = await resolveToken(instance);
const node = resolveToken(instance);
// Queries based on the requested dashboard structure
const queries = {
@@ -625,9 +565,6 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) {
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`,
conntrackEntries: `node_nf_conntrack_entries{instance="${node}",job="${job}"}`,
conntrackLimit: `node_nf_conntrack_entries_limit{instance="${node}",job="${job}"}`,
conntrackUsedPct: `(node_nf_conntrack_entries{instance="${node}",job="${job}"} / node_nf_conntrack_entries_limit{instance="${node}",job="${job}"}) * 100`,
// Get individual partitions (excluding virtual and FUSE mounts)
partitions_size: `node_filesystem_size_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`,
partitions_free: `node_filesystem_free_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`
@@ -653,85 +590,6 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) {
await Promise.all(queryPromises);
// Process custom metrics from settings
results.custom_data = [];
try {
const customMetrics = typeof settings.custom_metrics === 'string'
? JSON.parse(settings.custom_metrics)
: (settings.custom_metrics || []);
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 = [];
}
}
return {
name: cfg.name || cfg.metric,
value: val
};
}
} catch (e) {
console.error(`[Prometheus] Custom metric error (${cfg.metric}):`, e.message);
}
return null;
});
const customResults = await Promise.all(customPromises);
results.custom_data = customResults.filter(r => r !== null);
}
} catch (err) {
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 => {
@@ -772,7 +630,7 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) {
*/
async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') {
const url = normalizeUrl(baseUrl);
const node = await resolveToken(instance);
const node = resolveToken(instance);
// CPU Busy history: 100 - idle
if (metric === 'cpuBusy') {
@@ -797,8 +655,7 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st
netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`,
conntrackUsedPct: `(node_nf_conntrack_entries{instance="${node}",job="${job}"} / node_nf_conntrack_entries_limit{instance="${node}",job="${job}"}) * 100`
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`
};
const rangeObj = parseRange(range, start, end);
@@ -932,8 +789,10 @@ module.exports = {
getLatency: async (blackboxUrl, target) => {
if (!blackboxUrl || !target) return null;
try {
const normalized = normalizeUrl(blackboxUrl);
const normalized = blackboxUrl.trim().replace(/\/+$/, '');
// Construct a single optimized query searching for priority metrics and common labels
// Prioritize probe_icmp_duration_seconds OVER probe_duration_seconds
const queryExpr = `(
probe_icmp_duration_seconds{phase="rtt", instance="${target}"} or
probe_icmp_duration_seconds{phase="rtt", target="${target}"} or
@@ -945,15 +804,19 @@ module.exports = {
probe_duration_seconds{target="${target}"}
)`;
const result = await query(normalized, queryExpr);
if (result && result.length > 0) {
return parseFloat(result[0].value[1]) * 1000;
const params = new URLSearchParams({ query: queryExpr });
const res = await fetch(`${normalized}/api/v1/query?${params.toString()}`);
if (res.ok) {
const data = await res.json();
if (data.status === 'success' && data.data.result.length > 0) {
return parseFloat(data.data.result[0].value[1]) * 1000;
}
}
return null;
} catch (err) {
console.error(`[Prometheus] Error fetching latency for ${target}:`, err.message);
return null;
}
},
getCacheKey
}
};

43
vercel.json Normal file
View File

@@ -0,0 +1,43 @@
{
"version": 2,
"functions": {
"api/index.js": {
"runtime": "@vercel/node",
"includeFiles": "public/**"
}
},
"routes": [
{
"src": "/api/(.*)",
"dest": "/api/index.js"
},
{
"src": "/health",
"dest": "/api/index.js"
},
{
"src": "/init.html",
"dest": "/api/index.js"
},
{
"src": "/css/(.*)",
"dest": "/public/css/$1"
},
{
"src": "/js/(.*)",
"dest": "/public/js/$1"
},
{
"src": "/vendor/(.*)",
"dest": "/public/vendor/$1"
},
{
"src": "/(.*\\.(?:ico|png|jpg|jpeg|svg|webp|json|txt|xml))",
"dest": "/public/$1"
},
{
"src": "/(.*)",
"dest": "/api/index.js"
}
]
}