39 Commits

Author SHA1 Message Date
CN-JS-HuiBai
ba633c8be4 添加连接数指标评估 2026-04-18 16:18:13 +08:00
CN-JS-HuiBai
44843475c8 修复安全和请求策略问题 2026-04-14 16:56:46 +08:00
CN-JS-HuiBai
5e9dac6197 允许拖动95带宽展示 2026-04-14 14:53:05 +08:00
CN-JS-HuiBai
77743ce097 优化日本绘图 2026-04-13 15:43:47 +08:00
CN-JS-HuiBai
28667bcbee 优化中国台湾绘图 2026-04-13 15:42:00 +08:00
CN-JS-HuiBai
395b3f3c4e 优化界面布局 2026-04-13 15:34:03 +08:00
CN-JS-HuiBai
0c3ef3d07e 添加全局刷新按钮 2026-04-13 15:30:24 +08:00
CN-JS-HuiBai
83e8a96b2b 支持服务器详情24h流量统计 2026-04-13 15:21:26 +08:00
CN-JS-HuiBai
0a8efaf29d 修复打开页面时弹出登录页面的BUG 2026-04-12 18:05:17 +08:00
CN-JS-HuiBai
34bf4d5e91 优化后端逻辑 2026-04-12 17:56:49 +08:00
CN-JS-HuiBai
24b5f8455e 优化布局 2026-04-12 17:51:40 +08:00
CN-JS-HuiBai
e60fa2b982 优化数据库查询布局 2026-04-12 17:48:43 +08:00
CN-JS-HuiBai
b79cb09987 修改数据源查询逻辑 2026-04-12 17:37:26 +08:00
CN-JS-HuiBai
d7ac1bedb4 优化数据库稳定性 2026-04-11 18:14:04 +08:00
CN-JS-HuiBai
a876c854f4 优化系统初始化过程中数据库报错的问题 2026-04-11 17:57:26 +08:00
CN-JS-HuiBai
18c81fb8bb 修复数据库初始化的错误 2026-04-11 17:53:11 +08:00
CN-JS-HuiBai
d8c33ca3ee 修复数据库无法保存的BUG 2026-04-11 17:46:50 +08:00
CN-JS-HuiBai
27c2fb0b95 修复数据库查询错误导致的故障 2026-04-11 17:41:44 +08:00
CN-JS-HuiBai
bdc723d197 修复关键函数缺失导致的故障 2026-04-11 00:24:35 +08:00
CN-JS-HuiBai
531913a83c 完善报错信息 2026-04-11 00:20:44 +08:00
CN-JS-HuiBai
b7e7c173d7 修复了项目无法启动的错误 2026-04-11 00:12:03 +08:00
CN-JS-HuiBai
14796231f1 优化数据库自检 2026-04-11 00:10:36 +08:00
CN-JS-HuiBai
1826d4e34a 修复无法保存数据的错误 2026-04-11 00:06:18 +08:00
CN-JS-HuiBai
e373e1ce62 尝试修复页面无法正常加载的问题 2026-04-11 00:00:25 +08:00
CN-JS-HuiBai
4467b62a01 优化无法登录的错误 2026-04-10 23:53:25 +08:00
CN-JS-HuiBai
3519003a77 新增项目数据库自检流程 2026-04-10 23:44:46 +08:00
CN-JS-HuiBai
7362bcf206 修复项目逻辑错误
新增数据库检查
2026-04-10 23:42:55 +08:00
CN-JS-HuiBai
2cd6c6ef27 修复语法错误 2026-04-10 22:25:04 +08:00
CN-JS-HuiBai
cb27d1a249 加入自定义指标支持 2026-04-10 22:22:54 +08:00
CN-JS-HuiBai
710b6a719e 安全性能优化 2026-04-10 22:16:43 +08:00
CN-JS-HuiBai
66b5702d03 优化安全策略 2026-04-10 21:58:42 +08:00
CN-JS-HuiBai
90634dfacf 修改采集逻辑 2026-04-10 21:38:02 +08:00
CN-JS-HuiBai
c9784ec48e 新功能:允许查看服务器地址 2026-04-10 21:27:33 +08:00
CN-JS-HuiBai
cf1842f4e5 优化数据刷新间隔 2026-04-10 15:18:48 +08:00
CN-JS-HuiBai
f1a215d504 优化布局 2026-04-10 15:16:44 +08:00
CN-JS-HuiBai
9beba2a306 修复普罗米修斯数据库统计不准确的BUG 2026-04-10 15:10:52 +08:00
CN-JS-HuiBai
a3340cb630 修复数据源统计的选择影响服务器详情的BUG 2026-04-10 15:05:20 +08:00
CN-JS-HuiBai
5afcd3d86a 修复数据统计错误的问题 2026-04-10 14:56:06 +08:00
CN-JS-HuiBai
e65de2c30b 添加对指定数据源统计带宽 2026-04-10 14:49:27 +08:00
10 changed files with 2245 additions and 1524 deletions

View File

@@ -328,6 +328,33 @@ body {
transform: rotate(30deg); 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 ---- */
.theme-switch-wrapper { .theme-switch-wrapper {
display: flex; display: flex;
@@ -1458,7 +1485,7 @@ input:checked+.slider:before {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
z-index: 1000; z-index: 9999;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -2751,7 +2778,21 @@ input:checked+.slider:before {
.filings { .filings {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; 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;
}
} }
.filings a { .filings a {
@@ -2789,3 +2830,84 @@ input:checked+.slider:before {
justify-content: center; 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,6 +80,20 @@
observer.observe(document.documentElement, { childList: true, subtree: true }); observer.observe(document.documentElement, { childList: true, subtree: true });
})(); })();
</script> </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> </head>
<body> <body>
@@ -141,6 +155,13 @@
<div id="userSection"> <div id="userSection">
<button class="btn btn-login" id="btnLogin">登录</button> <button class="btn btn-login" id="btnLogin">登录</button>
</div> </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="配置管理"> <button class="btn-settings" id="btnSettings" title="配置管理">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round">
@@ -378,11 +399,13 @@
<th class="sortable" data-sort="disk">磁盘 <span class="sort-icon"></span></th> <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="netRx">网络 ↓ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="netTx">网络 ↑ <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> </tr>
</thead> </thead>
<tbody id="serverTableBody"> <tbody id="serverTableBody">
<tr class="empty-row"> <tr class="empty-row">
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td> <td colspan="10">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -412,9 +435,9 @@
<div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div> <div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div>
<div class="filings"> <div class="filings">
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank" id="psFilingDisplay" style="display: none;"> <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> <span id="psFilingText"></span>
</a> </a>
<span class="filing-sep"></span>
<a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a> <a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a>
</div> </div>
</div> </div>
@@ -427,6 +450,7 @@
<div class="modal-tabs"> <div class="modal-tabs">
<button class="modal-tab active" data-tab="prom">数据源管理</button> <button class="modal-tab active" data-tab="prom">数据源管理</button>
<button class="modal-tab" data-tab="site">大屏设置</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="latency">延迟线路管理</button>
<button class="modal-tab" data-tab="auth">账号安全</button> <button class="modal-tab" data-tab="auth">账号安全</button>
</div> </div>
@@ -459,17 +483,31 @@
<div class="form-row"> <div class="form-row">
<div class="form-group form-group-wide"> <div class="form-group form-group-wide">
<label for="sourceDesc">描述 (可选)</label> <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-group" id="serverSourceOption" </div>
style="display: flex; align-items: flex-end; padding-bottom: 8px;"> <div class="form-row" id="serverSourceOption" style="margin-top: 4px;">
<label <div class="form-group form-group-wide">
style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;"> <div class="source-options-clean-row">
<input type="checkbox" id="isServerSource" checked <label class="source-option-item" title="将此数据源的服务器指标聚合到首页总览中">
style="width: 16px; height: 16px; accent-color: var(--accent-indigo);"> <div class="switch-wrapper">
<span>用于服务器展示</span> <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> </label>
</div> </div>
<input type="checkbox" id="isServerSource" checked disabled style="display: none;">
</div>
</div>
<div class="form-row" style="margin-top: 8px;">
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-test" id="btnTest">测试连接</button> <button class="btn btn-test" id="btnTest">测试连接</button>
<button class="btn btn-add" id="btnAdd">添加</button> <button class="btn btn-add" id="btnAdd">添加</button>
@@ -507,15 +545,6 @@
<option value="0">隐藏 (Hide)</option> <option value="0">隐藏 (Hide)</option>
</select> </select>
</div> </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;"> <div class="form-group" style="margin-top: 15px;">
<label for="logoUrlInput">Logo URL (白天/默认,支持图片链接)</label> <label for="logoUrlInput">Logo URL (白天/默认,支持图片链接)</label>
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo_light.png"> <input type="url" id="logoUrlInput" placeholder="https://example.com/logo_light.png">
@@ -574,6 +603,65 @@
</div> </div>
</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 --> <!-- Latency Routes Tab -->
<div class="tab-content" id="tab-latency"> <div class="tab-content" id="tab-latency">
<div class="latency-settings-form"> <div class="latency-settings-form">
@@ -589,6 +677,7 @@
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);"> 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> <option value="">-- 选择数据源 --</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>起航点</label> <label>起航点</label>

View File

@@ -37,6 +37,8 @@
sourceDesc: document.getElementById('sourceDesc'), sourceDesc: document.getElementById('sourceDesc'),
btnTest: document.getElementById('btnTest'), btnTest: document.getElementById('btnTest'),
btnAdd: document.getElementById('btnAdd'), btnAdd: document.getElementById('btnAdd'),
isOverviewSource: document.getElementById('isOverviewSource'),
isDetailSource: document.getElementById('isDetailSource'),
isServerSource: document.getElementById('isServerSource'), isServerSource: document.getElementById('isServerSource'),
formMessage: document.getElementById('formMessage'), formMessage: document.getElementById('formMessage'),
sourceItems: document.getElementById('sourceItems'), sourceItems: document.getElementById('sourceItems'),
@@ -52,7 +54,10 @@
siteTitleInput: document.getElementById('siteTitleInput'), siteTitleInput: document.getElementById('siteTitleInput'),
logoUrlInput: document.getElementById('logoUrlInput'), logoUrlInput: document.getElementById('logoUrlInput'),
btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'), btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'),
btnSaveSecuritySettings: document.getElementById('btnSaveSecuritySettings'),
siteSettingsMessage: document.getElementById('siteSettingsMessage'), siteSettingsMessage: document.getElementById('siteSettingsMessage'),
securitySettingsMessage: document.getElementById('securitySettingsMessage'),
customMetricsMessage: document.getElementById('customMetricsMessage'),
logoText: document.getElementById('logoText'), logoText: document.getElementById('logoText'),
logoIconContainer: document.getElementById('logoIconContainer'), logoIconContainer: document.getElementById('logoIconContainer'),
defaultThemeInput: document.getElementById('defaultThemeInput'), defaultThemeInput: document.getElementById('defaultThemeInput'),
@@ -112,13 +117,21 @@
globeCard: document.getElementById('globeCard'), globeCard: document.getElementById('globeCard'),
btnExpandGlobe: document.getElementById('btnExpandGlobe'), btnExpandGlobe: document.getElementById('btnExpandGlobe'),
btnRefreshNetwork: document.getElementById('btnRefreshNetwork'), btnRefreshNetwork: document.getElementById('btnRefreshNetwork'),
btnGlobalRefresh: document.getElementById('btnGlobalRefresh'),
showServerIpInput: document.getElementById('showServerIpInput'),
ipMetricNameInput: document.getElementById('ipMetricNameInput'),
ipLabelNameInput: document.getElementById('ipLabelNameInput'),
// Footer & Filing // Footer & Filing
icpFilingInput: document.getElementById('icpFilingInput'), icpFilingInput: document.getElementById('icpFilingInput'),
psFilingInput: document.getElementById('psFilingInput'), psFilingInput: document.getElementById('psFilingInput'),
icpFilingDisplay: document.getElementById('icpFilingDisplay'), icpFilingDisplay: document.getElementById('icpFilingDisplay'),
psFilingDisplay: document.getElementById('psFilingDisplay'), psFilingDisplay: document.getElementById('psFilingDisplay'),
psFilingText: document.getElementById('psFilingText'), psFilingText: document.getElementById('psFilingText'),
copyrightYear: document.getElementById('copyrightYear') copyrightYear: document.getElementById('copyrightYear'),
customMetricsList: document.getElementById('customMetricsList'),
btnAddCustomMetric: document.getElementById('btnAddCustomMetric'),
btnSaveCustomMetrics: document.getElementById('btnSaveCustomMetrics'),
customDataContainer: document.getElementById('customDataContainer')
}; };
// ---- State ---- // ---- State ----
@@ -153,6 +166,8 @@
let myMap2D = null; let myMap2D = null;
let editingRouteId = null; let editingRouteId = null;
let allStoredSources = [];
let allStoredLatencyRoutes = [];
async function fetchJsonWithFallback(urls) { async function fetchJsonWithFallback(urls) {
let lastError = null; let lastError = null;
@@ -172,11 +187,77 @@
throw lastError || new Error('All JSON sources failed'); 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 ---- // ---- Initialize ----
function init() { 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 // Resource Gauges Time
updateGaugesTime(); updateGaugesTime();
setInterval(updateGaugesTime, 1000); backgroundIntervals.push(setInterval(updateGaugesTime, 1000));
// Initial footer year // Initial footer year
if (dom.copyrightYear) { if (dom.copyrightYear) {
@@ -202,7 +283,7 @@
dom.serverSourceOption.style.display = 'none'; dom.serverSourceOption.style.display = 'none';
dom.isServerSource.checked = false; dom.isServerSource.checked = false;
} else { } else {
dom.serverSourceOption.style.display = 'flex'; dom.serverSourceOption.style.display = '';
dom.isServerSource.checked = true; dom.isServerSource.checked = true;
} }
}); });
@@ -212,49 +293,78 @@
dom.btnCancelEditRoute.onclick = cancelEditRoute; dom.btnCancelEditRoute.onclick = cancelEditRoute;
} }
dom.settingsModal.addEventListener('click', (e) => {
if (e.target === dom.settingsModal) closeSettings();
});
dom.btnTest.addEventListener('click', testConnection);
dom.btnAdd.addEventListener('click', addSource);
// Auth & Theme listeners // Auth & Theme listeners
dom.themeToggle.addEventListener('change', toggleTheme); 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);
// System Theme Listener (Real-time) // Auth & Login
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: light)'); if (dom.btnLogin) dom.btnLogin.addEventListener('click', openLoginModal);
if (systemThemeMedia.addEventListener) { if (dom.closeLoginModal) dom.closeLoginModal.addEventListener('click', closeLoginModal);
systemThemeMedia.addEventListener('change', () => { if (dom.loginForm) dom.loginForm.addEventListener('submit', handleLogin);
const savedTheme = localStorage.getItem('theme') || (window.SITE_SETTINGS && window.SITE_SETTINGS.default_theme) || 'dark'; if (dom.loginModal) {
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) => { dom.loginModal.addEventListener('click', (e) => {
if (e.target === dom.loginModal) closeLoginModal(); if (e.target === dom.loginModal) closeLoginModal();
}); });
}
// Tab switching // Tab switching
if (dom.modalTabs) {
dom.modalTabs.forEach(tab => { dom.modalTabs.forEach(tab => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab'); const targetTab = tab.getAttribute('data-tab');
switchTab(targetTab); switchTab(targetTab);
}); });
}); });
}
// Site settings // Site settings
if (dom.btnSaveSiteSettings) {
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings); 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.btnAddRoute.addEventListener('click', addLatencyRoute);
}
// Auth password change // Auth password change
if (dom.btnChangePassword) { if (dom.btnChangePassword) {
dom.btnChangePassword.addEventListener('click', saveChangePassword); 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) // Globe expansion (FLIP animation via Web Animations API)
let savedGlobeRect = null; let savedGlobeRect = null;
let globeAnimating = false; let globeAnimating = false;
@@ -374,12 +484,10 @@
}); });
} }
if (dom.btnRefreshNetwork) { const handleGlobalRefresh = async (btn) => {
dom.btnRefreshNetwork.addEventListener('click', async () => { const icon = btn.querySelector('svg');
const icon = dom.btnRefreshNetwork.querySelector('svg');
if (icon) icon.style.animation = 'spin 0.8s ease-in-out'; if (icon) icon.style.animation = 'spin 0.8s ease-in-out';
// Force refresh all Prometheus 24h data and overview
await Promise.all([ await Promise.all([
fetchNetworkHistory(true), fetchNetworkHistory(true),
fetchMetrics(true) fetchMetrics(true)
@@ -390,7 +498,14 @@
icon.style.animation = ''; icon.style.animation = '';
}, 800); }, 800);
} }
}); };
if (dom.btnRefreshNetwork) {
dom.btnRefreshNetwork.addEventListener('click', () => handleGlobalRefresh(dom.btnRefreshNetwork));
}
if (dom.btnGlobalRefresh) {
dom.btnGlobalRefresh.addEventListener('click', () => handleGlobalRefresh(dom.btnGlobalRefresh));
} }
// Keyboard shortcut // Keyboard shortcut
@@ -538,15 +653,36 @@
if (dom.psFilingInput) dom.psFilingInput.value = window.SITE_SETTINGS.ps_filing || ''; 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.logoUrlDarkInput) dom.logoUrlDarkInput.value = window.SITE_SETTINGS.logo_url_dark || '';
if (dom.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || ''; if (dom.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || '';
// Latency routes loaded separately in openSettings or on startup if (dom.showServerIpInput) dom.showServerIpInput.value = window.SITE_SETTINGS.show_server_ip ? "1" : "0";
// Apply security dependency
updateSecurityDependency();
// Load custom metrics
loadCustomMetricsUI(window.SITE_SETTINGS.custom_metrics);
} }
loadSiteSettings(); 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 // Track intervals for resource management
initWebSocket(); initWebSocket();
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL)); backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_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);
}
} }
// ---- Real-time WebSocket ---- // ---- Real-time WebSocket ----
@@ -597,6 +733,14 @@
}; };
} }
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 ---- // ---- Theme Switching ----
function toggleTheme() { function toggleTheme() {
const theme = dom.themeToggle.checked ? 'light' : 'dark'; const theme = dom.themeToggle.checked ? 'light' : 'dark';
@@ -666,7 +810,9 @@
function updateUserUI(username) { function updateUserUI(username) {
if (username) { if (username) {
user = username; user = username;
dom.btnSettings.style.display = 'flex'; if (dom.btnSettings) dom.btnSettings.style.display = 'flex';
if (dom.btnGlobalRefresh) dom.btnGlobalRefresh.style.display = 'flex';
dom.userSection.innerHTML = ` dom.userSection.innerHTML = `
<div class="user-info"> <div class="user-info">
<span class="username">${escapeHtml(username)}</span> <span class="username">${escapeHtml(username)}</span>
@@ -676,7 +822,9 @@
document.getElementById('btnLogout').addEventListener('click', handleLogout); document.getElementById('btnLogout').addEventListener('click', handleLogout);
} else { } else {
user = null; user = null;
dom.btnSettings.style.display = 'none'; if (dom.btnSettings) dom.btnSettings.style.display = 'none';
if (dom.btnGlobalRefresh) dom.btnGlobalRefresh.style.display = 'none';
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`; dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
document.getElementById('btnLogin').addEventListener('click', openLoginModal); document.getElementById('btnLogin').addEventListener('click', openLoginModal);
} }
@@ -757,6 +905,16 @@
if (resp.ok) { if (resp.ok) {
updateUserUI(data.username); updateUserUI(data.username);
closeLoginModal(); 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 { } else {
dom.loginError.textContent = data.error || '登录失败'; dom.loginError.textContent = data.error || '登录失败';
dom.loginError.style.display = 'block'; dom.loginError.style.display = 'block';
@@ -1025,8 +1183,8 @@
'shanghai': [121.4737, 31.2304], 'shanghai': [121.4737, 31.2304],
'hong kong': [114.1694, 22.3193], 'hong kong': [114.1694, 22.3193],
'hk': [114.1694, 22.3193], 'hk': [114.1694, 22.3193],
'taiwan': [120.9605, 23.6978], 'taiwan': [121.5654, 25.0330],
'tw': [120.9605, 23.6978], 'tw': [121.5654, 25.0330],
'united states': [-95.7129, 37.0902], 'united states': [-95.7129, 37.0902],
'us': [-95.7129, 37.0902], 'us': [-95.7129, 37.0902],
'us seattle': [-122.3321, 47.6062], 'us seattle': [-122.3321, 47.6062],
@@ -1039,8 +1197,8 @@
'new york corp': [-74.0060, 40.7128], 'new york corp': [-74.0060, 40.7128],
'san francisco': [-122.4194, 37.7749], 'san francisco': [-122.4194, 37.7749],
'los angeles': [-118.2437, 34.0522], 'los angeles': [-118.2437, 34.0522],
'japan': [138.2529, 36.2048], 'japan': [139.6917, 35.6895],
'jp': [138.2529, 36.2048], 'jp': [139.6917, 35.6895],
'tokyo': [139.6917, 35.6895], 'tokyo': [139.6917, 35.6895],
'singapore': [103.8198, 1.3521], 'singapore': [103.8198, 1.3521],
'sg': [103.8198, 1.3521], 'sg': [103.8198, 1.3521],
@@ -1250,7 +1408,8 @@
</div>` }, </div>` },
{ key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(server.diskPercent) }, { key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(server.diskPercent) },
{ key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(server.netRx) }, { key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(server.netRx) },
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) } { key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) },
{ key: 'conntrackUsedPct', label: 'Conntrack 占用比例', value: formatPercent(server.conntrackPercent) }
]; ];
metrics.forEach(m => { metrics.forEach(m => {
@@ -1340,6 +1499,14 @@
valA = a.netTx ?? 0; valA = a.netTx ?? 0;
valB = b.netTx ?? 0; valB = b.netTx ?? 0;
break; 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: default:
valA = (a.job || '').toLowerCase(); valA = (a.job || '').toLowerCase();
valB = (b.job || '').toLowerCase(); valB = (b.job || '').toLowerCase();
@@ -1465,7 +1632,7 @@
if (!servers || servers.length === 0) { if (!servers || servers.length === 0) {
dom.serverTableBody.innerHTML = ` dom.serverTableBody.innerHTML = `
<tr class="empty-row"> <tr class="empty-row">
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td> <td colspan="10">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr> </tr>
`; `;
return; return;
@@ -1510,6 +1677,18 @@
</td> </td>
<td>${formatBandwidth(server.netRx)}</td> <td>${formatBandwidth(server.netRx)}</td>
<td>${formatBandwidth(server.netTx)}</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> </tr>
`; `;
}).join(''); }).join('');
@@ -1581,6 +1760,28 @@
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0); 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 // Define metrics to show
const cpuValueHtml = ` const cpuValueHtml = `
<div style="display: flex; align-items: baseline; gap: 8px;"> <div style="display: flex; align-items: baseline; gap: 8px;">
@@ -1602,6 +1803,11 @@
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) }, { key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) },
{ key: 'sockstatTcp', label: 'TCP 链接数 (Sockstat)', value: data.sockstatTcp.toFixed(0) }, { key: 'sockstatTcp', label: 'TCP 链接数 (Sockstat)', value: data.sockstatTcp.toFixed(0) },
{ key: 'sockstatTcpMem', label: 'TCP 内存占用', value: formatBytes(data.sockstatTcpMem) }, { 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: '' } { key: 'networkTrend', label: '网络流量趋势 (24h)', value: '' }
]; ];
@@ -1665,7 +1871,25 @@
</div> </div>
`).join(''); `).join('');
// Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric // Render Custom Data
const customDataContainer = dom.customDataContainer;
if (customDataContainer) {
customDataContainer.innerHTML = '';
if (data.custom_data && data.custom_data.length > 0) {
data.custom_data.forEach(item => {
const card = document.createElement('div');
card.className = 'detail-metric-card';
card.style.flex = '1 1 calc(50% - 10px)';
card.innerHTML = `
<span class="detail-metric-label">${escapeHtml(item.name)}</span>
<span class="detail-metric-value">${escapeHtml(item.value || '-')}</span>
`;
customDataContainer.appendChild(card);
});
}
}
// Partitions
if (data.partitions && data.partitions.length > 0) { if (data.partitions && data.partitions.length > 0) {
dom.detailPartitionsContainer.style.display = 'block'; dom.detailPartitionsContainer.style.display = 'block';
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`; dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
@@ -1676,6 +1900,19 @@
diskMetricItem.after(dom.detailPartitionsContainer); 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) => { dom.partitionHeader.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
dom.detailPartitionsContainer.classList.toggle('active'); dom.detailPartitionsContainer.classList.toggle('active');
@@ -1733,6 +1970,7 @@
if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%'; if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
if (metricKey.startsWith('net')) unit = 'B/s'; if (metricKey.startsWith('net')) unit = 'B/s';
if (metricKey === 'sockstatTcpMem') unit = 'B'; if (metricKey === 'sockstatTcpMem') unit = 'B';
if (metricKey === 'conntrackUsedPct') unit = '%';
if (metricKey === 'networkTrend') { if (metricKey === 'networkTrend') {
chart = new AreaChart(canvas); chart = new AreaChart(canvas);
@@ -1747,6 +1985,7 @@
if (metricKey === 'memUsedPct') chart.totalValue = currentServerDetail.memTotal; if (metricKey === 'memUsedPct') chart.totalValue = currentServerDetail.memTotal;
if (metricKey === 'swapUsedPct') chart.totalValue = currentServerDetail.swapTotal; if (metricKey === 'swapUsedPct') chart.totalValue = currentServerDetail.swapTotal;
if (metricKey === 'rootFsUsedPct') chart.totalValue = currentServerDetail.rootFsTotal; if (metricKey === 'rootFsUsedPct') chart.totalValue = currentServerDetail.rootFsTotal;
if (metricKey === 'conntrackUsedPct') chart.totalValue = data.conntrackLimit;
try { try {
const { instance, job, source } = currentServerDetail; const { instance, job, source } = currentServerDetail;
@@ -1928,12 +2167,24 @@
} }
}; };
siteThemeQuery.addEventListener('change', siteThemeHandler); 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) { } catch (err) {
console.error('Error loading site settings:', err); console.error('Error loading site settings:', err);
} }
} }
function applySiteSettings(settings) { function applySiteSettings(settings) {
if (!settings) return;
if (settings.page_name) { if (settings.page_name) {
document.title = settings.page_name; document.title = settings.page_name;
} }
@@ -1987,40 +2238,61 @@
} }
// Filing info // Filing info
let hasFilings = false; let hasPs = !!settings.ps_filing;
let hasIcp = !!settings.icp_filing;
if (dom.psFilingDisplay) { if (dom.psFilingDisplay) {
if (settings.ps_filing) { if (hasPs) {
if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing; if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing;
dom.psFilingDisplay.style.display = 'inline-block'; dom.psFilingDisplay.style.display = 'inline-block';
hasFilings = true;
} else { } else {
dom.psFilingDisplay.style.display = 'none'; dom.psFilingDisplay.style.display = 'none';
} }
} }
if (dom.icpFilingDisplay) { if (dom.icpFilingDisplay) {
if (settings.icp_filing) { if (hasIcp) {
dom.icpFilingDisplay.textContent = settings.icp_filing; dom.icpFilingDisplay.textContent = settings.icp_filing;
dom.icpFilingDisplay.style.display = 'inline-block'; dom.icpFilingDisplay.style.display = 'inline-block';
hasFilings = true;
} else { } else {
dom.icpFilingDisplay.style.display = 'none'; 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'); const footerContent = document.querySelector('.footer-content');
if (footerContent) { if (footerContent) {
footerContent.classList.toggle('only-copyright', !hasFilings); footerContent.classList.toggle('only-copyright', !(hasPs || hasIcp));
} }
} }
async function saveSiteSettings() { 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;
}
if (!user) { if (!user) {
showSiteMessage('请先登录后操作', 'error'); showSiteMessage('请先登录后操作', 'error', messageTarget);
openLoginModal(); openLoginModal();
return; 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 = { const settings = {
page_name: dom.pageNameInput.value.trim(), page_name: dom.pageNameInput.value.trim(),
title: dom.siteTitleInput ? dom.siteTitleInput.value.trim() : dom.pageNameInput.value.trim(), title: dom.siteTitleInput ? dom.siteTitleInput.value.trim() : dom.pageNameInput.value.trim(),
@@ -2033,12 +2305,13 @@
show_95_bandwidth: dom.show95BandwidthInput ? (dom.show95BandwidthInput.value === "1") : false, show_95_bandwidth: dom.show95BandwidthInput ? (dom.show95BandwidthInput.value === "1") : false,
p95_type: dom.p95TypeSelect ? dom.p95TypeSelect.value : 'tx', p95_type: dom.p95TypeSelect ? dom.p95TypeSelect.value : 'tx',
ps_filing: dom.psFilingInput ? dom.psFilingInput.value.trim() : '', ps_filing: dom.psFilingInput ? dom.psFilingInput.value.trim() : '',
icp_filing: dom.icpFilingInput ? dom.icpFilingInput.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()
}; };
dom.btnSaveSiteSettings.disabled = true;
dom.btnSaveSiteSettings.textContent = '保存中...';
try { try {
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
method: 'POST', method: 'POST',
@@ -2047,23 +2320,52 @@
}); });
if (response.ok) { if (response.ok) {
showSiteMessage('设置保存成功', 'success'); const data = await response.json();
// Update global object and UI immediately window.SITE_SETTINGS = data.settings;
window.SITE_SETTINGS = { ...window.SITE_SETTINGS, ...settings }; applySiteSettings(window.SITE_SETTINGS);
showSiteMessage('设置保存成功', 'success', messageTarget);
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
const themeToApply = savedTheme || settings.default_theme || 'dark'; const themeToApply = savedTheme || settings.default_theme || 'dark';
applyTheme(themeToApply); 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 { } else {
const err = await response.json(); const err = await response.json();
showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error'); showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error', messageTarget);
if (response.status === 401) openLoginModal(); if (response.status === 401) openLoginModal();
} }
} catch (err) { } catch (err) {
showSiteMessage(`保存失败: ${err.message}`, 'error'); showSiteMessage(`请求失败: ${err.message}`, 'error', messageTarget);
console.error('Save settings error:', err); console.error('Save settings error:', err);
} finally { } finally {
dom.btnSaveSiteSettings.disabled = false; saveButtons.forEach(btn => {
dom.btnSaveSiteSettings.textContent = '保存设置'; 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";
} }
} }
@@ -2076,6 +2378,7 @@
return; return;
} }
const routes = await response.json(); const routes = await response.json();
allStoredLatencyRoutes = routes;
renderLatencyRoutes(routes); renderLatencyRoutes(routes);
} catch (err) { } catch (err) {
console.error('Error loading latency routes:', err); console.error('Error loading latency routes:', err);
@@ -2089,7 +2392,7 @@
} }
dom.latencyRoutesList.innerHTML = routes.map(route => ` dom.latencyRoutesList.innerHTML = routes.map(route => `
<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="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="route-info" style="display: flex; flex-direction: column; gap: 4px;"> <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);"> <div style="font-weight: 600; font-size: 0.88rem; color: var(--text-primary);">
${escapeHtml(route.latency_source)}${escapeHtml(route.latency_dest)} ${escapeHtml(route.latency_source)}${escapeHtml(route.latency_dest)}
@@ -2100,25 +2403,30 @@
</div> </div>
</div> </div>
<div class="route-actions" style="display: flex; gap: 8px;"> <div class="route-actions" style="display: flex; gap: 8px;">
<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-test btn-edit-route" data-id="${route.id}" 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> <button class="btn btn-delete btn-delete-route" data-id="${route.id}" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
} }
window.editRoute = function (id, source_id, source, dest, target) { 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; editingRouteId = id;
dom.routeSourceSelect.value = source_id; dom.routeSourceSelect.value = route ? route.source_id : (source_id || '');
dom.routeSourceInput.value = source; dom.routeSourceInput.value = route ? route.latency_source : (source || '');
dom.routeDestInput.value = dest; dom.routeDestInput.value = route ? route.latency_dest : (dest || '');
dom.routeTargetInput.value = target; dom.routeTargetInput.value = route ? route.latency_target : (target || '');
dom.btnAddRoute.textContent = '保存修改'; dom.btnAddRoute.textContent = '保存修改';
dom.btnCancelEditRoute.style.display = 'block'; dom.btnCancelEditRoute.style.display = 'block';
// Select the tab just in case (though it's already there) // Select the tab 'latency' (not 'routes')
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes'); const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'latency');
if (tab) tab.click(); if (tab) tab.click();
}; };
@@ -2197,14 +2505,23 @@
} }
}; };
function showSiteMessage(text, type) { function showSiteMessage(text, type, target = null) {
dom.siteSettingsMessage.textContent = text; const el = target || dom.siteSettingsMessage;
dom.siteSettingsMessage.className = `form-message ${type}`; if (!el) return;
setTimeout(hideSiteMessage, 5000); 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 hideSiteMessage() { function hideSiteMessage(target = null) {
dom.siteSettingsMessage.className = 'form-message'; const el = target || dom.siteSettingsMessage;
if (el) el.className = 'form-message';
} }
async function saveChangePassword() { async function saveChangePassword() {
@@ -2306,13 +2623,20 @@
async function loadSources() { async function loadSources() {
try { 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'); const response = await fetch('/api/sources');
if (response.status === 401) { if (response.status === 401) {
if (dom.sourceItems) {
dom.sourceItems.innerHTML = '<div class="source-empty">请登录后管理数据源</div>';
}
promptLogin('登录后可查看和管理数据源'); promptLogin('登录后可查看和管理数据源');
return; return;
} }
const sources = await response.json(); const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : []; const sourcesArray = Array.isArray(sources) ? sources : [];
allStoredSources = sourcesArray;
const promSources = sourcesArray.filter(s => s.type !== 'blackbox'); const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`; if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`;
updateSourceFilterOptions(sourcesArray); updateSourceFilterOptions(sourcesArray);
@@ -2336,21 +2660,24 @@
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}"> <span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
${source.status === 'online' ? '在线' : '离线'} ${source.status === 'online' ? '在线' : '离线'}
</span> </span>
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}"> ${source.type === 'blackbox' ? '<span class="source-type-badge type-other">Blackbox</span>' : `
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')} ${source.is_overview_source ? '<span class="source-type-badge type-server" style="background: var(--accent-indigo);">总览</span>' : ''}
</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>' : ''}
`}
</div> </div>
<div class="source-item-url">${escapeHtml(source.url)}</div> <div class="source-item-url">${escapeHtml(source.url)}</div>
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''} ${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
</div> </div>
<div class="source-item-actions"> <div class="source-item-actions">
<button class="btn btn-secondary btn-sm" onclick="editSource(${JSON.stringify(source).replace(/"/g, '&quot;')})">编辑</button> <button class="btn btn-secondary btn-sm btn-edit-source" data-id="${source.id}">编辑</button>
<button class="btn btn-delete btn-sm" onclick="deleteSource(${source.id})">删除</button> <button class="btn btn-delete btn-sm btn-delete-source" data-id="${source.id}">删除</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
} }
// ---- Test Connection ---- // ---- Test Connection ----
async function testConnection() { async function testConnection() {
const url = dom.sourceUrl.value.trim(); const url = dom.sourceUrl.value.trim();
@@ -2392,19 +2719,26 @@
// ---- Add Source ---- // ---- Add Source ----
let editingSourceId = null; let editingSourceId = null;
window.editSource = function(source) { window.editSource = function(sourceOrId) {
let source = sourceOrId;
if (typeof sourceOrId !== 'object' && allStoredSources) {
source = allStoredSources.find(s => s.id === sourceOrId);
}
if (!source) return;
editingSourceId = source.id; editingSourceId = source.id;
dom.sourceName.value = source.name || ''; dom.sourceName.value = source.name || '';
dom.sourceUrl.value = source.url || ''; dom.sourceUrl.value = source.url || '';
dom.sourceType.value = source.type || 'prometheus'; dom.sourceType.value = source.type || 'prometheus';
dom.sourceDesc.value = source.description || ''; dom.sourceDesc.value = source.description || '';
dom.isServerSource.checked = !!source.is_server_source; if (dom.isOverviewSource) dom.isOverviewSource.checked = !!source.is_overview_source;
if (dom.isDetailSource) dom.isDetailSource.checked = !!source.is_detail_source;
// Toggle Blackbox UI // Toggle Blackbox UI
if (source.type === 'blackbox') { if (source.type === 'blackbox') {
dom.serverSourceOption.style.display = 'none'; dom.serverSourceOption.style.display = 'none';
} else { } else {
dom.serverSourceOption.style.display = 'flex'; dom.serverSourceOption.style.display = '';
} }
dom.btnAdd.textContent = '保存修改'; dom.btnAdd.textContent = '保存修改';
@@ -2430,8 +2764,9 @@
dom.sourceUrl.value = ''; dom.sourceUrl.value = '';
dom.sourceType.value = 'prometheus'; dom.sourceType.value = 'prometheus';
dom.sourceDesc.value = ''; dom.sourceDesc.value = '';
dom.isServerSource.checked = true; if (dom.isOverviewSource) dom.isOverviewSource.checked = true;
dom.serverSourceOption.style.display = 'flex'; if (dom.isDetailSource) dom.isDetailSource.checked = true;
dom.serverSourceOption.style.display = '';
dom.btnAdd.textContent = '添加'; dom.btnAdd.textContent = '添加';
const cancelBtn = document.getElementById('btnCancelEditSource'); const cancelBtn = document.getElementById('btnCancelEditSource');
@@ -2451,7 +2786,9 @@
const type = dom.sourceType.value; const type = dom.sourceType.value;
const description = dom.sourceDesc.value.trim(); const description = dom.sourceDesc.value.trim();
// Default to false for blackbox, otherwise use checkbox // Default to false for blackbox, otherwise use checkbox
const is_server_source = type === 'blackbox' ? false : dom.isServerSource.checked; 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;
if (!name || !url) { if (!name || !url) {
showMessage('请填写名称和URL', 'error'); showMessage('请填写名称和URL', 'error');
@@ -2469,7 +2806,7 @@
const response = await fetch(urlPath, { const response = await fetch(urlPath, {
method: method, method: method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, url, description, is_server_source, type }) body: JSON.stringify({ name, url, description, is_server_source, is_overview_source, is_detail_source, type })
}); });
if (response.ok) { if (response.ok) {

View File

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

View File

@@ -1,197 +0,0 @@
/**
* Database Integrity Check
* Runs at startup to ensure all required tables exist.
* Recreates the database if any tables are missing.
*/
require('dotenv').config();
const mysql = require('mysql2/promise');
const db = require('./db');
const path = require('path');
const fs = require('fs');
const REQUIRED_TABLES = [
'users',
'prometheus_sources',
'site_settings',
'traffic_stats',
'server_locations',
'latency_routes'
];
async function checkAndFixDatabase() {
const envPath = path.join(__dirname, '..', '.env');
if (!fs.existsSync(envPath)) return;
try {
// Check tables
const [rows] = await db.query("SHOW TABLES");
const existingTables = rows.map(r => Object.values(r)[0]);
const missingTables = REQUIRED_TABLES.filter(t => !existingTables.includes(t));
if (missingTables.length > 0) {
console.log(`[Database Integrity] ⚠️ Missing tables: ${missingTables.join(', ')}. Creating them...`);
for (const table of missingTables) {
await createTable(table);
}
console.log(`[Database Integrity] ✅ Missing tables created.`);
}
// Check for is_server_source and type in prometheus_sources
const [promColumns] = await db.query("SHOW COLUMNS FROM prometheus_sources");
const promColumnNames = promColumns.map(c => c.Field);
if (!promColumnNames.includes('is_server_source')) {
console.log(`[Database Integrity] ⚠️ Missing column 'is_server_source' in 'prometheus_sources'. Adding it...`);
await db.query("ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description");
console.log(`[Database Integrity] ✅ Column 'is_server_source' added.`);
}
if (!promColumnNames.includes('type')) {
console.log(`[Database Integrity] ⚠️ Missing column 'type' in 'prometheus_sources'. Adding it...`);
await db.query("ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source");
console.log(`[Database Integrity] ✅ Column 'type' added.`);
}
// Check for new columns in site_settings
const [columns] = await db.query("SHOW COLUMNS FROM site_settings");
const columnNames = columns.map(c => c.Field);
const addColumn = async (columnName, sql) => {
if (!columnNames.includes(columnName)) {
try {
console.log(`[Database Integrity] ⚠️ Missing column '${columnName}' in 'site_settings'. Adding it...`);
await db.query(sql);
console.log(`[Database Integrity] ✅ Column '${columnName}' added.`);
} catch (err) {
console.error(`[Database Integrity] ❌ Failed to add column '${columnName}':`, err.message);
// Try without AFTER if it exists
if (sql.includes('AFTER')) {
try {
const fallback = sql.split(' AFTER')[0];
console.log(`[Database Integrity] 🔄 Retrying column '${columnName}' WITHOUT 'AFTER'...`);
await db.query(fallback);
console.log(`[Database Integrity] ✅ Column '${columnName}' added via fallback.`);
} catch (err2) {
console.error(`[Database Integrity] ❌ Fallback also failed:`, err2.message);
}
}
}
}
};
await addColumn('show_page_name', "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name");
await addColumn('show_95_bandwidth', "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme");
await addColumn('p95_type', "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth");
await addColumn('require_login_for_server_details', "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type");
await addColumn('blackbox_source_id', "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type");
await addColumn('latency_source', "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id");
await addColumn('latency_dest', "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source");
await addColumn('latency_target', "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest");
await addColumn('icp_filing', "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target");
await addColumn('ps_filing', "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing");
await addColumn('logo_url_dark', "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url");
await addColumn('favicon_url', "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark");
} catch (err) {
console.error('[Database Integrity] ❌ Overall site_settings check error:', err.message);
}
}
async function createTable(tableName) {
console.log(` - Creating table "${tableName}"...`);
switch (tableName) {
case 'users':
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
salt VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
break;
case 'prometheus_sources':
await db.query(`
CREATE TABLE IF NOT EXISTS prometheus_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
break;
case 'site_settings':
await db.query(`
CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY DEFAULT 1,
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
show_page_name TINYINT(1) DEFAULT 1,
title VARCHAR(255) DEFAULT '数据可视化展示大屏',
logo_url TEXT,
logo_url_dark TEXT,
favicon_url TEXT,
default_theme VARCHAR(20) DEFAULT 'dark',
show_95_bandwidth TINYINT(1) DEFAULT 0,
p95_type VARCHAR(20) DEFAULT 'tx',
require_login_for_server_details TINYINT(1) DEFAULT 1,
blackbox_source_id INT,
latency_source VARCHAR(100),
latency_dest VARCHAR(100),
latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await db.query(`
INSERT IGNORE INTO site_settings (id, page_name, title, default_theme, show_95_bandwidth)
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0)
`);
break;
case 'traffic_stats':
await db.query(`
CREATE TABLE IF NOT EXISTS traffic_stats (
id INT AUTO_INCREMENT PRIMARY KEY,
rx_bytes BIGINT UNSIGNED DEFAULT 0,
tx_bytes BIGINT UNSIGNED DEFAULT 0,
rx_bandwidth DOUBLE DEFAULT 0,
tx_bandwidth DOUBLE DEFAULT 0,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
break;
case 'latency_routes':
await db.query(`
CREATE TABLE IF NOT EXISTS latency_routes (
id INT AUTO_INCREMENT PRIMARY KEY,
source_id INT NOT NULL,
latency_source VARCHAR(100) NOT NULL,
latency_dest VARCHAR(100) NOT NULL,
latency_target VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
break;
case 'server_locations':
await db.query(`
CREATE TABLE IF NOT EXISTS server_locations (
id INT AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(255) NOT NULL UNIQUE,
country CHAR(2),
country_name VARCHAR(100),
region VARCHAR(100),
city VARCHAR(100),
latitude DOUBLE,
longitude DOUBLE,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
break;
}
}
module.exports = checkAndFixDatabase;

View File

@@ -2,9 +2,9 @@
* Database schema check * Database schema check
* Ensures required tables and columns exist at startup. * Ensures required tables and columns exist at startup.
*/ */
require('dotenv').config();
const db = require('./db');
const path = require('path'); const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const db = require('./db');
const fs = require('fs'); const fs = require('fs');
const SCHEMA = { const SCHEMA = {
@@ -18,7 +18,11 @@ const SCHEMA = {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, `,
columns: [] 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" }
]
}, },
prometheus_sources: { prometheus_sources: {
createSql: ` createSql: `
@@ -28,29 +32,30 @@ const SCHEMA = {
url VARCHAR(500) NOT NULL, url VARCHAR(500) NOT NULL,
description TEXT, description TEXT,
is_server_source TINYINT(1) DEFAULT 1, 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', type VARCHAR(50) DEFAULT 'prometheus',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, `,
columns: [ columns: [
{ { name: 'name', sql: "ALTER TABLE prometheus_sources ADD COLUMN name VARCHAR(255) NOT NULL AFTER id" },
name: 'is_server_source', { name: 'url', sql: "ALTER TABLE prometheus_sources ADD COLUMN url VARCHAR(500) NOT NULL AFTER name" },
sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description" { 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: 'type', { name: 'is_detail_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_detail_source TINYINT(1) DEFAULT 1 AFTER is_overview_source" },
sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source" { name: 'type', sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_detail_source" }
}
] ]
}, },
site_settings: { site_settings: {
createSql: ` createSql: `
CREATE TABLE IF NOT EXISTS site_settings ( CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY DEFAULT 1, id INT PRIMARY KEY DEFAULT 1,
page_name VARCHAR(255) DEFAULT 'Data Visualization Display Wall', page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
show_page_name TINYINT(1) DEFAULT 1, show_page_name TINYINT(1) DEFAULT 1,
title VARCHAR(255) DEFAULT 'Data Visualization Display Wall', title VARCHAR(255) DEFAULT '数据可视化展示大屏',
logo_url TEXT, logo_url TEXT,
logo_url_dark TEXT, logo_url_dark TEXT,
favicon_url TEXT, favicon_url TEXT,
@@ -64,6 +69,10 @@ const SCHEMA = {
latency_target VARCHAR(255), latency_target VARCHAR(255),
icp_filing VARCHAR(255), icp_filing VARCHAR(255),
ps_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,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, `,
@@ -71,58 +80,30 @@ const SCHEMA = {
INSERT IGNORE INTO site_settings ( INSERT IGNORE INTO site_settings (
id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details
) VALUES ( ) VALUES (
1, 'Data Visualization Display Wall', 1, 'Data Visualization Display Wall', 'dark', 0, 'tx', 1 1, '数据可视化展示大屏', 1, '数据可视化展示大屏', 'dark', 0, 'tx', 1
) )
`, `,
columns: [ columns: [
{ { name: 'page_name', sql: "ALTER TABLE site_settings ADD COLUMN page_name VARCHAR(255) DEFAULT '数据可视化展示大屏' AFTER id" },
name: 'show_page_name', { name: 'show_page_name', sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER 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: 'logo_url_dark', { name: 'favicon_url', sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark" },
sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url" { 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: 'favicon_url', { 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" },
sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark" { 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: 'show_95_bandwidth', { name: 'latency_target', sql: "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest" },
sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme" { 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: 'p95_type', { name: 'ip_metric_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_metric_name VARCHAR(100) DEFAULT NULL AFTER show_server_ip" },
sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth" { 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: '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: { traffic_stats: {
@@ -137,7 +118,12 @@ const SCHEMA = {
UNIQUE INDEX (timestamp) UNIQUE INDEX (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, `,
columns: [] 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" }
]
}, },
server_locations: { server_locations: {
createSql: ` createSql: `
@@ -153,7 +139,15 @@ const SCHEMA = {
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, `,
columns: [] 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" }
]
}, },
latency_routes: { latency_routes: {
createSql: ` createSql: `
@@ -166,59 +160,74 @@ const SCHEMA = {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`, `,
columns: [] 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" }
]
} }
}; };
async function addColumnIfMissing(tableName, existingColumns, column) {
if (existingColumns.has(column.name)) {
return;
}
try {
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] 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 ensureTable(tableName, tableSchema) { async function ensureTable(tableName, tableSchema) {
try {
// 1. Ensure table exists
await db.query(tableSchema.createSql); await db.query(tableSchema.createSql);
// 2. Check columns
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``); const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
const existingColumns = new Set(columns.map((column) => column.Field)); 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 || []) { for (const column of tableSchema.columns || []) {
await addColumnIfMissing(tableName, existingColumns, column); 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) { 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); await db.query(tableSchema.seedSql);
} }
} }
} catch (err) {
console.error(`[Database Integrity] Error ensuring table '${tableName}':`, err.message);
throw err;
}
}
async function checkAndFixDatabase() { async function checkAndFixDatabase() {
const envPath = path.join(__dirname, '..', '.env'); console.log('[Database Integrity] Starting comprehensive database audit...');
if (!fs.existsSync(envPath)) return;
// 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
}
try { try {
let tablesChecked = 0;
for (const [tableName, tableSchema] of Object.entries(SCHEMA)) { for (const [tableName, tableSchema] of Object.entries(SCHEMA)) {
await ensureTable(tableName, tableSchema); await ensureTable(tableName, tableSchema);
tablesChecked++;
} }
console.log(`[Database Integrity] Audit complete. ${tablesChecked} tables verified and healthy.`);
return true;
} catch (err) { } catch (err) {
console.error('[Database Integrity] Startup schema check failed:', err.message); console.error('[Database Integrity] ❌ Audit failed:', err.message);
throw err;
} }
} }

View File

@@ -18,21 +18,48 @@ const enableExternalGeoLookup = process.env.ENABLE_EXTERNAL_GEO_LOOKUP === 'true
function normalizeGeo(geo) { function normalizeGeo(geo) {
if (!geo) return geo; if (!geo) return geo;
// Custom normalization for TW, HK, MO to "China, {CODE}" // Custom normalization for TW to "Taipei, China" and JP to "Tokyo"
const specialRegions = ['TW']; const country = (geo.country || geo.country_code || '').toUpperCase();
if (specialRegions.includes(geo.country?.toUpperCase())) { if (country === 'TW') {
return { return {
...geo, ...geo,
city: `China, ${geo.country.toUpperCase()}`, city: 'Taipei',
country_name: 'China' 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
}; };
} }
return geo; return geo;
} }
async function getLocation(target) { async function getLocation(target) {
// Normalize target (strip port if present) // Normalize target (strip port if present, handle IPv6 brackets)
const cleanTarget = target.split(':')[0]; 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];
}
}
// 1. Check if we already have this IP/Domain in DB (FASTEST) // 1. Check if we already have this IP/Domain in DB (FASTEST)
try { try {
@@ -58,7 +85,18 @@ async function getLocation(target) {
// Secondary DB check with resolved IP // Secondary DB check with resolved IP
const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]); const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]);
if (rows.length > 0) { if (rows.length > 0) {
return normalizeGeo(rows[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);
} }
} catch (err) { } catch (err) {
// Quiet DNS failure for tokens (legacy bug mitigation) // Quiet DNS failure for tokens (legacy bug mitigation)
@@ -118,6 +156,29 @@ async function getLocation(target) {
locationData.longitude 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; return locationData;
} }
} catch (err) { } catch (err) {

View File

@@ -1,7 +1,7 @@
require('dotenv').config(); const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path');
const db = require('./db'); const db = require('./db');
const prometheusService = require('./prometheus-service'); const prometheusService = require('./prometheus-service');
const cache = require('./cache'); const cache = require('./cache');
@@ -28,6 +28,8 @@ const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 864
const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000; const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000;
const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true'; const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true';
const COOKIE_SECURE = process.env.COOKIE_SECURE === '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 = { const RATE_LIMITS = {
login: { windowMs: 15 * 60 * 1000, max: 8 }, login: { windowMs: 15 * 60 * 1000, max: 8 },
setup: { windowMs: 10 * 60 * 1000, max: 20 } setup: { windowMs: 10 * 60 * 1000, max: 20 }
@@ -144,7 +146,12 @@ function getPublicSiteSettings(settings = {}) {
? (settings.require_login_for_server_details ? 1 : 0) ? (settings.require_login_for_server_details ? 1 : 0)
: 1, : 1,
icp_filing: settings.icp_filing || null, icp_filing: settings.icp_filing || null,
ps_filing: settings.ps_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 || []
}; };
} }
@@ -156,6 +163,18 @@ async function getSiteSettingsRow() {
async function requireServerDetailsAccess(req, res, next) { async function requireServerDetailsAccess(req, res, next) {
try { try {
const settings = await getSiteSettingsRow(); 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 const requiresLogin = settings.require_login_for_server_details !== undefined
? !!settings.require_login_for_server_details ? !!settings.require_login_for_server_details
: true; : true;
@@ -167,7 +186,7 @@ async function requireServerDetailsAccess(req, res, next) {
return requireAuth(req, res, next); return requireAuth(req, res, next);
} catch (err) { } catch (err) {
console.error('Server details access check failed:', err); console.error('Server details access check failed:', err);
return res.status(500).json({ error: 'Failed to verify detail access' }); return res.status(500).json({ error: '权限验证失败' });
} }
} }
@@ -298,15 +317,25 @@ function getCookie(req, name) {
return matches ? decodeURIComponent(matches[1]) : undefined; return matches ? decodeURIComponent(matches[1]) : undefined;
} }
/**
* Database Initialization Check
*/
async function checkDb() { async function checkDb() {
try { try {
const fs = require('fs'); const fs = require('fs');
if (!fs.existsSync(path.join(__dirname, '..', '.env'))) { // 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) {
isDbInitialized = false; isDbInitialized = false;
return; return;
} }
// Check for essential tables
const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'"); const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'");
isDbInitialized = rows.length > 0; const [rows2] = await db.query("SHOW TABLES LIKE 'site_settings'");
isDbInitialized = rows.length > 0 && rows2.length > 0;
} catch (err) { } catch (err) {
isDbInitialized = false; isDbInitialized = false;
} }
@@ -545,6 +574,11 @@ app.post('/api/setup/init', ensureSetupAccess, async (req, res) => {
latency_target VARCHAR(255), latency_target VARCHAR(255),
icp_filing VARCHAR(255), icp_filing VARCHAR(255),
ps_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 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
@@ -553,11 +587,7 @@ app.post('/api/setup/init', ensureSetupAccess, async (req, res) => {
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0, 'tx') VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0, 'tx')
`); `);
// Ensure the first-run schema matches the runtime expectations without requiring a restart migration. // Note: checkAndFixDatabase (called later in this route) will handle column migrations correctly and compatibly.
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(` await connection.query(`
CREATE TABLE IF NOT EXISTS latency_routes ( CREATE TABLE IF NOT EXISTS latency_routes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -601,6 +631,7 @@ COOKIE_SECURE=${process.env.COOKIE_SECURE || 'false'}
SESSION_TTL_SECONDS=${process.env.SESSION_TTL_SECONDS || SESSION_TTL_SECONDS} SESSION_TTL_SECONDS=${process.env.SESSION_TTL_SECONDS || SESSION_TTL_SECONDS}
PASSWORD_ITERATIONS=${process.env.PASSWORD_ITERATIONS || PASSWORD_ITERATIONS} PASSWORD_ITERATIONS=${process.env.PASSWORD_ITERATIONS || PASSWORD_ITERATIONS}
ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'} 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); fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
@@ -618,6 +649,9 @@ ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'}
db.initPool(); db.initPool();
cache.init(); cache.init();
// Run the migration/centralized schema tool to create/fix all tables
await checkAndFixDatabase();
isDbInitialized = true; isDbInitialized = true;
res.json({ success: true, message: 'Initialization complete' }); res.json({ success: true, message: 'Initialization complete' });
} catch (err) { } catch (err) {
@@ -727,7 +761,8 @@ const serveIndex = async (req, res) => {
latency_dest: null, latency_dest: null,
latency_target: null, latency_target: null,
icp_filing: null, icp_filing: null,
ps_filing: null ps_filing: null,
network_data_sources: null
}; };
if (isDbInitialized) { if (isDbInitialized) {
@@ -773,7 +808,8 @@ app.get('/api/sources', requireAuth, async (req, res) => {
const res = await fetch(`${source.url.replace(/\/+$/, '')}/metrics`, { timeout: 3000 }).catch(() => null); const res = await fetch(`${source.url.replace(/\/+$/, '')}/metrics`, { timeout: 3000 }).catch(() => null);
response = (res && res.ok) ? 'Blackbox Exporter Ready' : 'Connection Error'; response = (res && res.ok) ? 'Blackbox Exporter Ready' : 'Connection Error';
} else { } else {
response = await prometheusService.testConnection(source.url); // Use a shorter timeout for list view to prevent blocking UI
response = await prometheusService.testConnection(source.url, 2500);
} }
return { ...source, status: 'online', version: response }; return { ...source, status: 'online', version: response };
} catch (e) { } catch (e) {
@@ -796,8 +832,16 @@ app.post('/api/sources', requireAuth, async (req, res) => {
if (!/^https?:\/\//i.test(url)) url = 'http://' + url; if (!/^https?:\/\//i.test(url)) url = 'http://' + url;
try { try {
const [result] = await db.query( const [result] = await db.query(
'INSERT INTO prometheus_sources (name, url, description, is_server_source, type) VALUES (?, ?, ?, ?, ?)', '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), type || 'prometheus'] [
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'
]
); );
const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]); const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]);
@@ -817,8 +861,17 @@ app.put('/api/sources/:id', requireAuth, async (req, res) => {
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url; if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
try { try {
await db.query( await db.query(
'UPDATE prometheus_sources SET name = ?, url = ?, description = ?, is_server_source = ?, type = ? WHERE id = ?', '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, type || 'prometheus', req.params.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
]
); );
// Clear network history cache // Clear network history cache
await cache.del('network_history_all'); await cache.del('network_history_all');
@@ -873,25 +926,6 @@ app.get('/api/settings', async (req, res) => {
return res.json(getPublicSiteSettings()); return res.json(getPublicSiteSettings());
} }
return res.json(getPublicSiteSettings(rows[0])); 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) { } catch (err) {
console.error('Error fetching settings:', err); console.error('Error fetching settings:', err);
res.status(500).json({ error: 'Failed to fetch settings' }); res.status(500).json({ error: 'Failed to fetch settings' });
@@ -909,7 +943,7 @@ app.post('/api/settings', requireAuth, async (req, res) => {
const { const {
page_name, show_page_name, title, logo_url, logo_url_dark, favicon_url, 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, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details,
icp_filing, ps_filing icp_filing, ps_filing, show_server_ip, ip_metric_name, ip_label_name, custom_metrics
} = req.body; } = req.body;
// 3. Prepare parameters, prioritizing body but falling back to current // 3. Prepare parameters, prioritizing body but falling back to current
@@ -926,22 +960,26 @@ app.post('/api/settings', requireAuth, async (req, res) => {
require_login_for_server_details: require_login_for_server_details !== undefined require_login_for_server_details: require_login_for_server_details !== undefined
? (require_login_for_server_details ? 1 : 0) ? (require_login_for_server_details ? 1 : 0)
: (current.require_login_for_server_details !== undefined ? current.require_login_for_server_details : 1), : (current.require_login_for_server_details !== undefined ? current.require_login_for_server_details : 1),
blackbox_source_id: current.blackbox_source_id || null, // UI doesn't send this blackbox_source_id: current.blackbox_source_id || null,
latency_source: current.latency_source || null, // UI doesn't send this latency_source: current.latency_source || null,
latency_dest: current.latency_dest || null, // UI doesn't send this latency_dest: current.latency_dest || null,
latency_target: current.latency_target || null, // UI doesn't send this latency_target: current.latency_target || null,
icp_filing: icp_filing !== undefined ? icp_filing : (current.icp_filing || null), icp_filing: icp_filing !== undefined ? icp_filing : (current.icp_filing || null),
ps_filing: ps_filing !== undefined ? ps_filing : (current.ps_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 || '[]')
}; };
// 4. Update database await db.query(`
await db.query( INSERT INTO site_settings (
`INSERT INTO site_settings (
id, page_name, show_page_name, title, logo_url, logo_url_dark, favicon_url, 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, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details,
blackbox_source_id, latency_source, latency_dest, latency_target, blackbox_source_id, latency_source, latency_dest, latency_target,
icp_filing, ps_filing icp_filing, ps_filing, show_server_ip, ip_metric_name, ip_label_name,
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) custom_metrics
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
page_name = VALUES(page_name), page_name = VALUES(page_name),
show_page_name = VALUES(show_page_name), show_page_name = VALUES(show_page_name),
@@ -958,16 +996,21 @@ app.post('/api/settings', requireAuth, async (req, res) => {
latency_dest = VALUES(latency_dest), latency_dest = VALUES(latency_dest),
latency_target = VALUES(latency_target), latency_target = VALUES(latency_target),
icp_filing = VALUES(icp_filing), icp_filing = VALUES(icp_filing),
ps_filing = VALUES(ps_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)`,
[ [
settings.page_name, settings.show_page_name, settings.title, settings.logo_url, settings.logo_url_dark, settings.favicon_url, 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.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.blackbox_source_id, settings.latency_source, settings.latency_dest, settings.latency_target,
settings.icp_filing, settings.ps_filing settings.icp_filing, settings.ps_filing, settings.show_server_ip,
settings.ip_metric_name, settings.ip_label_name, settings.custom_metrics
] ]
); );
res.json({ success: true }); res.json({ success: true, settings: getPublicSiteSettings(settings) });
} catch (err) { } catch (err) {
console.error('Error updating settings:', err); console.error('Error updating settings:', err);
res.status(500).json({ error: 'Failed to update settings' }); res.status(500).json({ error: 'Failed to update settings' });
@@ -978,7 +1021,9 @@ app.post('/api/settings', requireAuth, async (req, res) => {
// Reusable function to get overview metrics // Reusable function to get overview metrics
async function getOverview(force = false) { async function getOverview(force = false) {
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"'); // 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"');
if (sources.length === 0) { if (sources.length === 0) {
return { return {
totalServers: 0, totalServers: 0,
@@ -991,7 +1036,6 @@ async function getOverview(force = false) {
servers: [] servers: []
}; };
} }
const allMetrics = await Promise.all(sources.map(async (source) => { const allMetrics = await Promise.all(sources.map(async (source) => {
const cacheKey = `source_metrics:${source.url}:${source.name}`; const cacheKey = `source_metrics:${source.url}:${source.name}`;
if (force) { if (force) {
@@ -1003,10 +1047,15 @@ async function getOverview(force = false) {
try { try {
const metrics = await prometheusService.getOverviewMetrics(source.url, source.name); const metrics = await prometheusService.getOverviewMetrics(source.url, source.name);
// Don't set cache here if we want real-time WS push to be fresh, const enrichedMetrics = {
// but keeping it for REST API performance is fine. ...metrics,
await cache.set(cacheKey, metrics, 15); // Cache for 15s sourceName: source.name,
return metrics; isOverview: !!source.is_overview_source,
isDetail: !!source.is_detail_source
};
await cache.set(cacheKey, enrichedMetrics, 15); // Cache for 15s
return enrichedMetrics;
} catch (err) { } catch (err) {
console.error(`Error fetching metrics from ${source.name}:`, err.message); console.error(`Error fetching metrics from ${source.name}:`, err.message);
return null; return null;
@@ -1015,30 +1064,62 @@ async function getOverview(force = false) {
const validMetrics = allMetrics.filter(m => m !== null); const validMetrics = allMetrics.filter(m => m !== null);
// Aggregate across all sources // Use Maps to deduplicate servers across multiple Prometheus sources
let totalServers = 0; 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;
let activeServers = 0; let activeServers = 0;
let cpuUsed = 0, cpuTotal = 0; let cpuUsed = 0, cpuTotal = 0;
let memUsed = 0, memTotal = 0; let memUsed = 0, memTotal = 0;
let diskUsed = 0, diskTotal = 0; let diskUsed = 0, diskTotal = 0;
let netRx = 0, netTx = 0; let netRx = 0, netTx = 0;
let traffic24hRx = 0, traffic24hTx = 0; let traffic24hRx = 0, traffic24hTx = 0;
let allServers = [];
for (const m of validMetrics) { for (const inst of allOverviewServers) {
totalServers += m.totalServers; if (inst.up) {
activeServers += (m.activeServers !== undefined ? m.activeServers : m.totalServers); activeServers++;
cpuUsed += m.cpu.used; cpuUsed += (inst.cpuPercent / 100) * inst.cpuCores;
cpuTotal += m.cpu.total; cpuTotal += inst.cpuCores;
memUsed += m.memory.used; memUsed += inst.memUsed;
memTotal += m.memory.total; memTotal += inst.memTotal;
diskUsed += m.disk.used; diskUsed += inst.diskUsed;
diskTotal += m.disk.total; diskTotal += inst.diskTotal;
netRx += m.network.rx; netRx += inst.netRx || 0;
netTx += m.network.tx; netTx += inst.netTx || 0;
traffic24hRx += m.traffic24h.rx; traffic24hRx += inst.traffic24hRx || 0;
traffic24hTx += m.traffic24h.tx; traffic24hTx += inst.traffic24hTx || 0;
allServers = allServers.concat(m.servers); }
} }
const overview = { const overview = {
@@ -1069,13 +1150,27 @@ async function getOverview(force = false) {
tx: traffic24hTx, tx: traffic24hTx,
total: traffic24hRx + traffic24hTx total: traffic24hRx + traffic24hTx
}, },
servers: allServers servers: allDetailServers
}; };
// --- Add Geo Information to Servers --- // --- Add Geo Information to Servers ---
const geoServers = await Promise.all(overview.servers.map(async (server) => { const geoServers = await Promise.all(overview.servers.map(async (server) => {
const realInstance = server.originalInstance || prometheusService.resolveToken(server.instance); const realInstance = server.originalInstance || await prometheusService.resolveToken(server.instance);
const cleanIp = realInstance.split(':')[0]; // 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
}
let geoData = null; let geoData = null;
try { try {
@@ -1130,7 +1225,8 @@ app.get('/api/metrics/network-history', async (req, res) => {
if (cached) return res.json(cached); if (cached) return res.json(cached);
} }
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"'); const query = 'SELECT * FROM prometheus_sources WHERE is_overview_source = 1 AND type != "blackbox"';
const [sources] = await db.query(query);
if (sources.length === 0) { if (sources.length === 0) {
return res.json({ timestamps: [], rx: [], tx: [] }); return res.json({ timestamps: [], rx: [], tx: [] });
} }
@@ -1159,7 +1255,7 @@ app.get('/api/metrics/network-history', async (req, res) => {
// Get CPU usage history for sparklines // Get CPU usage history for sparklines
app.get('/api/metrics/cpu-history', async (req, res) => { app.get('/api/metrics/cpu-history', async (req, res) => {
try { try {
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"'); const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_overview_source = 1 AND type != "blackbox"');
if (sources.length === 0) { if (sources.length === 0) {
return res.json({ timestamps: [], values: [] }); return res.json({ timestamps: [], values: [] });
} }
@@ -1200,8 +1296,15 @@ app.get('/api/metrics/server-details', requireServerDetailsAccess, async (req, r
} }
const sourceUrl = rows[0].url; const sourceUrl = rows[0].url;
// Fetch detailed metrics // Fetch detailed metrics with custom metric configuration if present
const details = await prometheusService.getServerDetails(sourceUrl, instance, job); 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;
}
res.json(details); res.json(details);
} catch (err) { } catch (err) {
console.error(`Error fetching server details for ${instance}:`, err.message); console.error(`Error fetching server details for ${instance}:`, err.message);
@@ -1234,12 +1337,6 @@ app.get('/api/metrics/server-history', requireServerDetailsAccess, async (req, r
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
});
// SPA fallback
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.includes('.')) return next();
serveIndex(req, res);
});
// ==================== Latency Routes CRUD ==================== // ==================== Latency Routes CRUD ====================
@@ -1332,6 +1429,8 @@ app.get('/api/metrics/latency', async (req, res) => {
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocket.Server({ server }); const wss = new WebSocket.Server({ server });
let isBroadcastRunning = false; let isBroadcastRunning = false;
let cachedLatencyRoutes = null;
let lastRoutesUpdate = 0;
function broadcast(data) { function broadcast(data) {
const message = JSON.stringify(data); const message = JSON.stringify(data);
@@ -1342,21 +1441,25 @@ function broadcast(data) {
}); });
} }
async function broadcastMetrics() { async function broadcastMetrics() {
if (isBroadcastRunning) return; if (isBroadcastRunning) return;
isBroadcastRunning = true; isBroadcastRunning = true;
try { try {
const overview = await getOverview(); const overview = await getOverview();
// 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(` const [routes] = await db.query(`
SELECT r.*, s.url, s.type as source_type SELECT r.*, s.url, s.type as source_type
FROM latency_routes r FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id 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}`); let latency = await cache.get(`latency:route:${route.id}`);
if (latency === null && route.source_type === 'prometheus') { if (latency === null && route.source_type === 'prometheus') {
latency = await prometheusService.getLatency(route.url, route.latency_target); latency = await prometheusService.getLatency(route.url, route.latency_target);
@@ -1387,8 +1490,28 @@ async function broadcastMetrics() {
async function start() { async function start() {
try { try {
console.log('🔧 Initializing services...'); console.log('🔧 Initializing services...');
console.log('🔧 Initializing services...');
// Ensure DB is ready before starting anything else // 1. Initial check
await checkDb();
// 2. Automated repair/migration
try {
const dbFixed = await checkAndFixDatabase();
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 // Start services
latencyService.start(); latencyService.start();
@@ -1396,6 +1519,26 @@ async function start() {
const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000; const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000;
setInterval(broadcastMetrics, REFRESH_INT); 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, () => { server.listen(PORT, HOST, () => {
console.log(`\n 🚀 Data Visualization Display Wall (WebSocket Enabled)`); console.log(`\n 🚀 Data Visualization Display Wall (WebSocket Enabled)`);
console.log(` 📊 Server running at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`); console.log(` 📊 Server running at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
@@ -1407,4 +1550,12 @@ async function start() {
} }
} }
process.on('unhandledRejection', (reason, promise) => {
console.error('[System] Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (err) => {
console.error('[System] Uncaught Exception:', err);
});
start(); start();

View File

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

View File

@@ -1,6 +1,7 @@
const axios = require('axios'); const axios = require('axios');
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const cache = require('./cache'); // <-- ADD
const QUERY_TIMEOUT = 10000; const QUERY_TIMEOUT = 10000;
@@ -9,14 +10,34 @@ const crypto = require('crypto');
const httpAgent = new http.Agent({ keepAlive: true }); const httpAgent = new http.Agent({ keepAlive: true });
const httpsAgent = new https.Agent({ keepAlive: true }); const httpsAgent = new https.Agent({ keepAlive: true });
const serverIdMap = new Map(); // token -> { instance, job, source } const serverIdMap = new Map(); // token -> { instance, job, source, lastSeen }
const SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex');
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
function getServerToken(instance, job, source) { function getServerToken(instance, job, source) {
const hash = crypto.createHmac('sha256', SECRET) const hash = crypto.createHmac('sha256', getSecret())
.update(`${instance}:${job}:${source}`) .update(`${instance}:${job}:${source}`)
.digest('hex') .digest('hex')
.substring(0, 16); .substring(0, 16);
// Update lastSeen timestamp
const data = serverIdMap.get(hash);
if (data) data.lastSeen = Date.now();
return hash; return hash;
} }
@@ -48,12 +69,12 @@ function createClient(baseUrl) {
/** /**
* Test Prometheus connection * Test Prometheus connection
*/ */
async function testConnection(url) { async function testConnection(url, customTimeout = null) {
const normalized = normalizeUrl(url); const normalized = normalizeUrl(url);
try { try {
// Using native fetch to avoid follow-redirects/axios "protocol mismatch" issues in some Node environments // Using native fetch to avoid follow-redirects/axios "protocol mismatch" issues in some Node environments
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QUERY_TIMEOUT); const timer = setTimeout(() => controller.abort(), customTimeout || QUERY_TIMEOUT);
// Node native fetch - handles http/https automatically // Node native fetch - handles http/https automatically
const res = await fetch(`${normalized}/api/v1/status/buildinfo`, { const res = await fetch(`${normalized}/api/v1/status/buildinfo`, {
@@ -188,7 +209,11 @@ async function getOverviewMetrics(url, sourceName) {
diskFreeResult, diskFreeResult,
netRxResult, netRxResult,
netTxResult, netTxResult,
targetsResult netRx24hResult,
netTx24hResult,
targetsResult,
conntrackEntriesResult,
conntrackLimitResult
] = await Promise.all([ ] = await Promise.all([
// CPU usage per instance: 1 - avg idle // CPU usage per instance: 1 - avg idle
query(url, '100 - (avg by (instance, job) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)').catch(() => []), query(url, '100 - (avg by (instance, job) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)').catch(() => []),
@@ -206,8 +231,16 @@ async function getOverviewMetrics(url, sourceName) {
query(url, 'sum by (instance, job) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []), query(url, 'sum by (instance, job) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
// Network transmit rate (bytes/sec) // Network transmit rate (bytes/sec)
query(url, 'sum by (instance, job) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []), 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 // Targets status from /api/v1/targets
getTargets(url).catch(() => []) getTargets(url).catch(() => []),
// Conntrack entries
query(url, 'node_nf_conntrack_entries').catch(() => []),
// Conntrack limits
query(url, 'node_nf_conntrack_entries_limit').catch(() => [])
]); ]);
// Fetch 24h detailed traffic using the A*duration logic // Fetch 24h detailed traffic using the A*duration logic
@@ -222,7 +255,10 @@ async function getOverviewMetrics(url, sourceName) {
const token = getServerToken(originalInstance, job, sourceName); const token = getServerToken(originalInstance, job, sourceName);
// Store mapping for detail queries // Store mapping for detail queries
serverIdMap.set(token, { instance: originalInstance, source: sourceName, job }); 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(()=>{});
if (!instances.has(token)) { if (!instances.has(token)) {
instances.set(token, { instances.set(token, {
@@ -238,9 +274,14 @@ async function getOverviewMetrics(url, sourceName) {
diskUsed: 0, diskUsed: 0,
netRx: 0, netRx: 0,
netTx: 0, netTx: 0,
traffic24hRx: 0,
traffic24hTx: 0,
conntrackEntries: 0,
conntrackLimit: 0,
up: false, up: false,
memPercent: 0, memPercent: 0,
diskPercent: 0 diskPercent: 0,
conntrackPercent: 0
}); });
} }
const inst = instances.get(token); const inst = instances.get(token);
@@ -306,6 +347,26 @@ async function getOverviewMetrics(url, sourceName) {
inst.netTx = parseFloat(r.value[1]) || 0; 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()) { for (const inst of instances.values()) {
if (!inst.up && (inst.cpuPercent > 0 || inst.memTotal > 0)) { if (!inst.up && (inst.cpuPercent > 0 || inst.memTotal > 0)) {
inst.up = true; inst.up = true;
@@ -313,6 +374,7 @@ async function getOverviewMetrics(url, sourceName) {
// Calculate percentages on backend // Calculate percentages on backend
inst.memPercent = inst.memTotal > 0 ? (inst.memUsed / inst.memTotal * 100) : 0; inst.memPercent = inst.memTotal > 0 ? (inst.memUsed / inst.memTotal * 100) : 0;
inst.diskPercent = inst.diskTotal > 0 ? (inst.diskUsed / inst.diskTotal * 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()); const allInstancesList = Array.from(instances.values());
@@ -391,25 +453,23 @@ function calculateTrafficFromHistory(values) {
} }
/** /**
* Get total traffic for the past 24h by fetching all points and integrating * Get total traffic for the past 24h using Prometheus increase() for stability and accuracy
*/ */
async function get24hTrafficSum(url) { async function get24hTrafficSum(url) {
const now = Math.floor(Date.now() / 1000); try {
const start = now - 86400;
const step = 60; // 1-minute points for calculation
const [rxResult, txResult] = await Promise.all([ 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(() => []), query(url, 'sum(increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
queryRange(url, 'sum(rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))', start, now, step).catch(() => []) query(url, 'sum(increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => [])
]); ]);
const rxValues = rxResult.length > 0 ? rxResult[0].values : []; const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0;
const txValues = txResult.length > 0 ? txResult[0].values : []; const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0;
return { return { rx, tx };
rx: calculateTrafficFromHistory(rxValues), } catch (err) {
tx: calculateTrafficFromHistory(txValues) console.error(`[Prometheus] get24hTrafficSum error:`, err.message);
}; return { rx: 0, tx: 0 };
}
} }
/** /**
@@ -417,34 +477,28 @@ async function get24hTrafficSum(url) {
*/ */
async function get24hServerTrafficSum(url, instance, job) { async function get24hServerTrafficSum(url, instance, job) {
const node = resolveToken(instance); const node = resolveToken(instance);
const now = Math.floor(Date.now() / 1000);
const start = now - 86400;
const step = 60;
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 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(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 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 [rxResult, txResult] = await Promise.all([ const [rxResult, txResult] = await Promise.all([
queryRange(url, rxExpr, start, now, step).catch(() => []), query(url, rxExpr).catch(() => []),
queryRange(url, txExpr, start, now, step).catch(() => []) query(url, txExpr).catch(() => [])
]); ]);
const rxValues = rxResult.length > 0 ? rxResult[0].values : []; const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0;
const txValues = txResult.length > 0 ? txResult[0].values : []; const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0;
return { return { rx, tx };
rx: calculateTrafficFromHistory(rxValues),
tx: calculateTrafficFromHistory(txValues)
};
} }
/** /**
* Get network traffic history (past 24h, 5-min intervals for chart) * Get network traffic history (past 24h, 5-min intervals for chart)
*/ */
async function getNetworkHistory(url) { async function getNetworkHistory(url) {
const now = Math.floor(Date.now() / 1000);
const start = now - 86400; // 24h ago
const step = 300; // 5 minutes for better resolution on chart const step = 300; // 5 minutes for better resolution on chart
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
const start = now - 86400; // 24h ago
const [rxResult, txResult] = await Promise.all([ const [rxResult, txResult] = await Promise.all([
queryRange(url, queryRange(url,
@@ -496,9 +550,9 @@ function mergeNetworkHistories(histories) {
* Get CPU usage history (past 1h, 1-min intervals) * Get CPU usage history (past 1h, 1-min intervals)
*/ */
async function getCpuHistory(url) { async function getCpuHistory(url) {
const now = Math.floor(Date.now() / 1000);
const start = now - 3600; // 1h ago
const step = 60; // 1 minute const step = 60; // 1 minute
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
const start = now - 3600; // 1h ago
const result = await queryRange(url, const result = await queryRange(url,
'100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)', '100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)',
@@ -533,19 +587,22 @@ function mergeCpuHistories(histories) {
} }
function resolveToken(token) { async function resolveToken(token) {
if (serverIdMap.has(token)) { if (serverIdMap.has(token)) {
return serverIdMap.get(token).instance; return serverIdMap.get(token).instance;
} }
const cachedInstance = await cache.get(`server_token:${token}`);
if (cachedInstance) return cachedInstance;
return token; return token;
} }
/** /**
* Get detailed metrics for a specific server (node) * Get detailed metrics for a specific server (node)
*/ */
async function getServerDetails(baseUrl, instance, job) { async function getServerDetails(baseUrl, instance, job, settings = {}) {
const url = normalizeUrl(baseUrl); const url = normalizeUrl(baseUrl);
const node = resolveToken(instance); const node = await resolveToken(instance);
// Queries based on the requested dashboard structure // Queries based on the requested dashboard structure
const queries = { const queries = {
@@ -565,6 +622,9 @@ async function getServerDetails(baseUrl, instance, job) {
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]))`, 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}"}`, sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`, 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) // 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_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/.*"}` partitions_free: `node_filesystem_free_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`
@@ -590,6 +650,85 @@ async function getServerDetails(baseUrl, instance, job) {
await Promise.all(queryPromises); 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 // Group partitions
const partitionsMap = {}; const partitionsMap = {};
(results.partitions_size || []).forEach(p => { (results.partitions_size || []).forEach(p => {
@@ -630,7 +769,7 @@ async function getServerDetails(baseUrl, instance, job) {
*/ */
async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') { async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') {
const url = normalizeUrl(baseUrl); const url = normalizeUrl(baseUrl);
const node = resolveToken(instance); const node = await resolveToken(instance);
// CPU Busy history: 100 - idle // CPU Busy history: 100 - idle
if (metric === 'cpuBusy') { if (metric === 'cpuBusy') {
@@ -655,7 +794,8 @@ 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]))`, 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]))`, 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}"}`, sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096` 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`
}; };
const rangeObj = parseRange(range, start, end); const rangeObj = parseRange(range, start, end);
@@ -789,10 +929,8 @@ module.exports = {
getLatency: async (blackboxUrl, target) => { getLatency: async (blackboxUrl, target) => {
if (!blackboxUrl || !target) return null; if (!blackboxUrl || !target) return null;
try { try {
const normalized = blackboxUrl.trim().replace(/\/+$/, ''); const normalized = normalizeUrl(blackboxUrl);
// Construct a single optimized query searching for priority metrics and common labels
// Prioritize probe_icmp_duration_seconds OVER probe_duration_seconds
const queryExpr = `( const queryExpr = `(
probe_icmp_duration_seconds{phase="rtt", instance="${target}"} or probe_icmp_duration_seconds{phase="rtt", instance="${target}"} or
probe_icmp_duration_seconds{phase="rtt", target="${target}"} or probe_icmp_duration_seconds{phase="rtt", target="${target}"} or
@@ -804,14 +942,9 @@ module.exports = {
probe_duration_seconds{target="${target}"} probe_duration_seconds{target="${target}"}
)`; )`;
const params = new URLSearchParams({ query: queryExpr }); const result = await query(normalized, queryExpr);
const res = await fetch(`${normalized}/api/v1/query?${params.toString()}`); if (result && result.length > 0) {
return parseFloat(result[0].value[1]) * 1000;
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; return null;
} catch (err) { } catch (err) {