1 Commits

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

3
api/index.js Normal file
View File

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

View File

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

View File

@@ -328,33 +328,6 @@ 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;
@@ -1485,7 +1458,7 @@ input:checked+.slider:before {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
z-index: 9999; z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -2778,21 +2751,7 @@ input:checked+.slider:before {
.filings { .filings {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 20px;
}
.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 {
@@ -2830,84 +2789,3 @@ 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,20 +80,6 @@
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>
@@ -155,13 +141,6 @@
<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">
@@ -399,13 +378,11 @@
<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="10">暂无数据 - 请先配置 Prometheus 数据源</td> <td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -435,9 +412,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>
@@ -450,7 +427,6 @@
<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>
@@ -483,31 +459,17 @@
<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> <div class="form-group" id="serverSourceOption"
<div class="form-row" id="serverSourceOption" style="margin-top: 4px;"> style="display: flex; align-items: flex-end; padding-bottom: 8px;">
<div class="form-group form-group-wide"> <label
<div class="source-options-clean-row"> style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
<label class="source-option-item" title="将此数据源的服务器指标聚合到首页总览中"> <input type="checkbox" id="isServerSource" checked
<div class="switch-wrapper"> style="width: 16px; height: 16px; accent-color: var(--accent-indigo);">
<input type="checkbox" id="isOverviewSource" checked class="switch-input"> <span>用于服务器展示</span>
<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>
@@ -545,6 +507,15 @@
<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">
@@ -588,11 +559,6 @@
<option value="max">出入取大 (Max)</option> <option value="max">出入取大 (Max)</option>
</select> </select>
</div> </div>
<div class="form-group" style="margin-top: 15px;">
<label for="prometheusCacheTtlInput">数据自动刷新/同步间隔 (秒)</label>
<input type="number" id="prometheusCacheTtlInput" placeholder="例30" min="0" max="86400">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">后端将按此频率主动从 Prometheus 抓取数据并缓存。设为 0 则禁用自动同步。建议值15-60s。</p>
</div>
<div class="form-group" style="margin-top: 15px;"> <div class="form-group" style="margin-top: 15px;">
<label for="psFilingInput">公安备案号 (如:京公网安备 11010102000001号)</label> <label for="psFilingInput">公安备案号 (如:京公网安备 11010102000001号)</label>
<input type="text" id="psFilingInput" placeholder="请输入公安备案号"> <input type="text" id="psFilingInput" placeholder="请输入公安备案号">
@@ -601,11 +567,6 @@
<label for="icpFilingInput">ICP 备案号 (如京ICP备12345678号)</label> <label for="icpFilingInput">ICP 备案号 (如京ICP备12345678号)</label>
<input type="text" id="icpFilingInput" placeholder="请输入 ICP 备案号"> <input type="text" id="icpFilingInput" placeholder="请输入 ICP 备案号">
</div> </div>
<div class="form-group" style="margin-top: 15px;">
<label for="cdnUrlInput">静态资源 CDN 地址 (例如: https://cdn.example.com)</label>
<input type="url" id="cdnUrlInput" placeholder="留空则使用本地服务器资源">
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,页面中的 JS/CSS/图片等资源将尝试从该 CDN 加载。请确保 CDN 已正确镜像相关资源。</p>
</div>
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;"> <div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
<button class="btn btn-add" id="btnSaveSiteSettings">保存基础设置</button> <button class="btn btn-add" id="btnSaveSiteSettings">保存基础设置</button>
</div> </div>
@@ -613,65 +574,6 @@
</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">
@@ -687,7 +589,6 @@
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);"> 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,8 +37,6 @@
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'),
@@ -54,10 +52,7 @@
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'),
@@ -117,23 +112,13 @@
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'),
cdnUrlInput: document.getElementById('cdnUrlInput'),
prometheusCacheTtlInput: document.getElementById('prometheusCacheTtlInput')
}; };
// ---- State ---- // ---- State ----
@@ -153,7 +138,10 @@
let siteThemeQuery = null; // For media query cleanup let siteThemeQuery = null; // For media query cleanup
let siteThemeHandler = null; let siteThemeHandler = null;
let backgroundIntervals = []; // To track setIntervals let backgroundIntervals = []; // To track setIntervals
let realtimeIntervalId = null;
let lastMapDataHash = ''; // Cache for map rendering optimization let lastMapDataHash = ''; // Cache for map rendering optimization
const appRuntime = window.APP_RUNTIME || {};
const prefersPollingRealtime = appRuntime.realtimeMode === 'polling';
// Load sort state from localStorage or use default // Load sort state from localStorage or use default
let currentSort = { column: 'up', direction: 'desc' }; let currentSort = { column: 'up', direction: 'desc' };
@@ -168,8 +156,6 @@
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;
@@ -189,77 +175,11 @@
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();
backgroundIntervals.push(setInterval(updateGaugesTime, 1000)); setInterval(updateGaugesTime, 1000);
// Initial footer year // Initial footer year
if (dom.copyrightYear) { if (dom.copyrightYear) {
@@ -285,7 +205,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 = ''; dom.serverSourceOption.style.display = 'flex';
dom.isServerSource.checked = true; dom.isServerSource.checked = true;
} }
}); });
@@ -295,78 +215,49 @@
dom.btnCancelEditRoute.onclick = cancelEditRoute; dom.btnCancelEditRoute.onclick = cancelEditRoute;
} }
// Auth & Theme listeners dom.settingsModal.addEventListener('click', (e) => {
if (dom.themeToggle) dom.themeToggle.addEventListener('change', toggleTheme); if (e.target === dom.settingsModal) closeSettings();
if (dom.btnSettings) dom.btnSettings.addEventListener('click', openSettings); });
if (dom.modalClose) dom.modalClose.addEventListener('click', closeSettings); dom.btnTest.addEventListener('click', testConnection);
if (dom.btnTest) dom.btnTest.addEventListener('click', testConnection); dom.btnAdd.addEventListener('click', addSource);
if (dom.btnAdd) dom.btnAdd.addEventListener('click', addSource);
// Auth & Login // Auth & Theme listeners
if (dom.btnLogin) dom.btnLogin.addEventListener('click', openLoginModal); dom.themeToggle.addEventListener('change', toggleTheme);
if (dom.closeLoginModal) dom.closeLoginModal.addEventListener('click', closeLoginModal);
if (dom.loginForm) dom.loginForm.addEventListener('submit', handleLogin); // System Theme Listener (Real-time)
if (dom.loginModal) { const systemThemeMedia = window.matchMedia('(prefers-color-scheme: light)');
if (systemThemeMedia.addEventListener) {
systemThemeMedia.addEventListener('change', () => {
const savedTheme = localStorage.getItem('theme') || (window.SITE_SETTINGS && window.SITE_SETTINGS.default_theme) || 'dark';
if (savedTheme === 'auto') {
applyTheme('auto');
}
});
}
dom.btnLogin.addEventListener('click', openLoginModal);
dom.closeLoginModal.addEventListener('click', closeLoginModal);
dom.loginForm.addEventListener('submit', handleLogin);
dom.loginModal.addEventListener('click', (e) => { 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;
@@ -486,10 +377,12 @@
}); });
} }
const handleGlobalRefresh = async (btn) => { if (dom.btnRefreshNetwork) {
const icon = btn.querySelector('svg'); dom.btnRefreshNetwork.addEventListener('click', async () => {
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)
@@ -500,14 +393,7 @@
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
@@ -655,40 +541,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 || '';
if (dom.showServerIpInput) dom.showServerIpInput.value = window.SITE_SETTINGS.show_server_ip ? "1" : "0"; // Latency routes loaded separately in openSettings or on startup
if (dom.prometheusCacheTtlInput) dom.prometheusCacheTtlInput.value = window.SITE_SETTINGS.prometheus_cache_ttl !== undefined ? window.SITE_SETTINGS.prometheus_cache_ttl : 30;
// Apply security dependency
updateSecurityDependency();
// Load custom metrics
loadCustomMetricsUI(window.SITE_SETTINGS.custom_metrics);
} }
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
if (prefersPollingRealtime) {
startRealtimePolling();
} else {
initWebSocket(); initWebSocket();
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);
} }
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
} }
// ---- Real-time WebSocket ---- // ---- Real-time WebSocket ----
function stopRealtimePolling() {
if (realtimeIntervalId) {
clearInterval(realtimeIntervalId);
realtimeIntervalId = null;
}
}
function startRealtimePolling() {
if (realtimeIntervalId) return;
fetchRealtimeOverview();
realtimeIntervalId = setInterval(fetchRealtimeOverview, REFRESH_INTERVAL);
backgroundIntervals.push(realtimeIntervalId);
}
function initWebSocket() { function initWebSocket() {
if (isWsConnecting) return; if (isWsConnecting) return;
isWsConnecting = true; isWsConnecting = true;
@@ -706,6 +588,7 @@
ws.onopen = () => { ws.onopen = () => {
isWsConnecting = false; isWsConnecting = false;
stopRealtimePolling();
console.log('WS connection established'); console.log('WS connection established');
}; };
@@ -726,6 +609,7 @@
ws.onclose = () => { ws.onclose = () => {
isWsConnecting = false; isWsConnecting = false;
startRealtimePolling();
console.log('WS connection closed. Reconnecting in 5s...'); console.log('WS connection closed. Reconnecting in 5s...');
setTimeout(initWebSocket, 5000); setTimeout(initWebSocket, 5000);
}; };
@@ -736,14 +620,6 @@
}; };
} }
function formatClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
return h + ':' + m + ':' + s;
}
// ---- Theme Switching ---- // ---- Theme Switching ----
function toggleTheme() { function toggleTheme() {
const theme = dom.themeToggle.checked ? 'light' : 'dark'; const theme = dom.themeToggle.checked ? 'light' : 'dark';
@@ -813,9 +689,7 @@
function updateUserUI(username) { function updateUserUI(username) {
if (username) { if (username) {
user = username; user = username;
if (dom.btnSettings) dom.btnSettings.style.display = 'flex'; 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>
@@ -825,9 +699,7 @@
document.getElementById('btnLogout').addEventListener('click', handleLogout); document.getElementById('btnLogout').addEventListener('click', handleLogout);
} else { } else {
user = null; user = null;
if (dom.btnSettings) dom.btnSettings.style.display = 'none'; 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);
} }
@@ -908,16 +780,6 @@
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';
@@ -952,6 +814,19 @@
} }
// ---- Fetch Metrics ---- // ---- Fetch Metrics ----
async function fetchRealtimeOverview(force = false) {
try {
const url = `/api/realtime/overview${force ? '?force=true' : ''}`;
const response = await fetch(url);
const data = await response.json();
allServersData = data.servers || [];
currentLatencies = data.latencies || [];
updateDashboard(data);
} catch (err) {
console.error('Error fetching realtime overview:', err);
}
}
async function fetchMetrics(force = false) { async function fetchMetrics(force = false) {
try { try {
const url = `/api/metrics/overview${force ? '?force=true' : ''}`; const url = `/api/metrics/overview${force ? '?force=true' : ''}`;
@@ -1186,8 +1061,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': [121.5654, 25.0330], 'taiwan': [120.9605, 23.6978],
'tw': [121.5654, 25.0330], 'tw': [120.9605, 23.6978],
'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],
@@ -1200,8 +1075,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': [139.6917, 35.6895], 'japan': [138.2529, 36.2048],
'jp': [139.6917, 35.6895], 'jp': [138.2529, 36.2048],
'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],
@@ -1411,8 +1286,7 @@
</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 => {
@@ -1502,14 +1376,6 @@
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();
@@ -1635,7 +1501,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="10">暂无数据 - 请先配置 Prometheus 数据源</td> <td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr> </tr>
`; `;
return; return;
@@ -1680,18 +1546,6 @@
</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('');
@@ -1763,28 +1617,6 @@
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;">
@@ -1806,11 +1638,6 @@
{ 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: '' }
]; ];
@@ -1874,25 +1701,7 @@
</div> </div>
`).join(''); `).join('');
// Render Custom Data // Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric
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} 个本地分区`;
@@ -1903,19 +1712,6 @@
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');
@@ -1973,7 +1769,6 @@
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);
@@ -1988,7 +1783,6 @@
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;
@@ -2149,14 +1943,13 @@
if (dom.faviconUrlInput) dom.faviconUrlInput.value = settings.favicon_url || ''; if (dom.faviconUrlInput) dom.faviconUrlInput.value = settings.favicon_url || '';
if (dom.showPageNameInput) dom.showPageNameInput.value = settings.show_page_name !== undefined ? settings.show_page_name.toString() : "1"; if (dom.showPageNameInput) dom.showPageNameInput.value = settings.show_page_name !== undefined ? settings.show_page_name.toString() : "1";
if (dom.requireLoginForServerDetailsInput) dom.requireLoginForServerDetailsInput.value = settings.require_login_for_server_details ? "1" : "0"; if (dom.requireLoginForServerDetailsInput) dom.requireLoginForServerDetailsInput.value = settings.require_login_for_server_details ? "1" : "0";
if (dom.showServerIpInput) dom.showServerIpInput.value = settings.show_server_ip ? "1" : "0";
if (dom.cdnUrlInput) dom.cdnUrlInput.value = settings.cdn_url || '';
if (dom.prometheusCacheTtlInput) dom.prometheusCacheTtlInput.value = settings.prometheus_cache_ttl !== undefined ? settings.prometheus_cache_ttl : 30;
// Handle Theme Priority: localStorage > Site Default // Handle Theme Priority: localStorage > Site Default
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);
// Listen for system theme changes if set to auto (cleanup existing listener first)
if (siteThemeQuery && siteThemeHandler) { if (siteThemeQuery && siteThemeHandler) {
siteThemeQuery.removeEventListener('change', siteThemeHandler); siteThemeQuery.removeEventListener('change', siteThemeHandler);
} }
@@ -2171,24 +1964,12 @@
} }
}; };
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;
} }
@@ -2242,61 +2023,40 @@
} }
// Filing info // Filing info
let hasPs = !!settings.ps_filing; let hasFilings = false;
let hasIcp = !!settings.icp_filing;
if (dom.psFilingDisplay) { if (dom.psFilingDisplay) {
if (hasPs) { if (settings.ps_filing) {
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 (hasIcp) { if (settings.icp_filing) {
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', !(hasPs || hasIcp)); footerContent.classList.toggle('only-copyright', !hasFilings);
} }
} }
async function saveSiteSettings(e) { async function saveSiteSettings() {
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', messageTarget); showSiteMessage('请先登录后操作', 'error');
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(),
@@ -2309,15 +2069,12 @@
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(),
cdn_url: dom.cdnUrlInput ? dom.cdnUrlInput.value.trim() : '',
prometheus_cache_ttl: dom.prometheusCacheTtlInput ? parseInt(dom.prometheusCacheTtlInput.value) : 30
}; };
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',
@@ -2326,52 +2083,23 @@
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); showSiteMessage('设置保存成功', 'success');
window.SITE_SETTINGS = data.settings; // Update global object and UI immediately
applySiteSettings(window.SITE_SETTINGS); window.SITE_SETTINGS = { ...window.SITE_SETTINGS, ...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', messageTarget); showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error');
if (response.status === 401) openLoginModal(); if (response.status === 401) openLoginModal();
} }
} catch (err) { } catch (err) {
showSiteMessage(`请求失败: ${err.message}`, 'error', messageTarget); showSiteMessage(`保存失败: ${err.message}`, 'error');
console.error('Save settings error:', err); console.error('Save settings error:', err);
} finally { } finally {
saveButtons.forEach(btn => { dom.btnSaveSiteSettings.disabled = false;
btn.disabled = false; dom.btnSaveSiteSettings.textContent = '保存设置';
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";
} }
} }
@@ -2384,7 +2112,6 @@
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);
@@ -2398,7 +2125,7 @@
} }
dom.latencyRoutesList.innerHTML = routes.map(route => ` dom.latencyRoutesList.innerHTML = routes.map(route => `
<div class="latency-route-item" data-id="${route.id}" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;"> <div class="latency-route-item" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;">
<div class="route-info" style="display: flex; flex-direction: column; gap: 4px;"> <div 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)}
@@ -2409,30 +2136,25 @@
</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 btn-edit-route" data-id="${route.id}" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button> <button class="btn btn-test" onclick="editRoute(${route.id}, ${route.source_id}, '${escapeHtml(route.latency_source)}', '${escapeHtml(route.latency_dest)}', '${escapeHtml(route.latency_target)}')" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button>
<button class="btn btn-delete btn-delete-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>
</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 = route ? route.source_id : (source_id || ''); dom.routeSourceSelect.value = source_id;
dom.routeSourceInput.value = route ? route.latency_source : (source || ''); dom.routeSourceInput.value = source;
dom.routeDestInput.value = route ? route.latency_dest : (dest || ''); dom.routeDestInput.value = dest;
dom.routeTargetInput.value = route ? route.latency_target : (target || ''); dom.routeTargetInput.value = target;
dom.btnAddRoute.textContent = '保存修改'; dom.btnAddRoute.textContent = '保存修改';
dom.btnCancelEditRoute.style.display = 'block'; dom.btnCancelEditRoute.style.display = 'block';
// Select the tab 'latency' (not 'routes') // Select the tab just in case (though it's already there)
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'latency'); const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
if (tab) tab.click(); if (tab) tab.click();
}; };
@@ -2511,23 +2233,14 @@
} }
}; };
function showSiteMessage(text, type, target = null) { function showSiteMessage(text, type) {
const el = target || dom.siteSettingsMessage; dom.siteSettingsMessage.textContent = text;
if (!el) return; dom.siteSettingsMessage.className = `form-message ${type}`;
el.textContent = text; setTimeout(hideSiteMessage, 5000);
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(target = null) { function hideSiteMessage() {
const el = target || dom.siteSettingsMessage; dom.siteSettingsMessage.className = 'form-message';
if (el) el.className = 'form-message';
} }
async function saveChangePassword() { async function saveChangePassword() {
@@ -2629,20 +2342,13 @@
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);
@@ -2666,24 +2372,21 @@
<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>
${source.type === 'blackbox' ? '<span class="source-type-badge type-other">Blackbox</span>' : ` <span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
${source.is_overview_source ? '<span class="source-type-badge type-server" style="background: var(--accent-indigo);">总览</span>' : ''} ${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
${source.is_detail_source ? '<span class="source-type-badge type-server" style="background: var(--accent-emerald);">详情</span>' : ''} </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 btn-edit-source" data-id="${source.id}">编辑</button> <button class="btn btn-secondary btn-sm" onclick="editSource(${JSON.stringify(source).replace(/"/g, '&quot;')})">编辑</button>
<button class="btn btn-delete btn-sm btn-delete-source" data-id="${source.id}">删除</button> <button class="btn btn-delete btn-sm" onclick="deleteSource(${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();
@@ -2725,26 +2428,19 @@
// ---- Add Source ---- // ---- Add Source ----
let editingSourceId = null; let editingSourceId = null;
window.editSource = function(sourceOrId) { window.editSource = function(source) {
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 || '';
if (dom.isOverviewSource) dom.isOverviewSource.checked = !!source.is_overview_source; dom.isServerSource.checked = !!source.is_server_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 = ''; dom.serverSourceOption.style.display = 'flex';
} }
dom.btnAdd.textContent = '保存修改'; dom.btnAdd.textContent = '保存修改';
@@ -2770,9 +2466,8 @@
dom.sourceUrl.value = ''; dom.sourceUrl.value = '';
dom.sourceType.value = 'prometheus'; dom.sourceType.value = 'prometheus';
dom.sourceDesc.value = ''; dom.sourceDesc.value = '';
if (dom.isOverviewSource) dom.isOverviewSource.checked = true; dom.isServerSource.checked = true;
if (dom.isDetailSource) dom.isDetailSource.checked = true; dom.serverSourceOption.style.display = 'flex';
dom.serverSourceOption.style.display = '';
dom.btnAdd.textContent = '添加'; dom.btnAdd.textContent = '添加';
const cancelBtn = document.getElementById('btnCancelEditSource'); const cancelBtn = document.getElementById('btnCancelEditSource');
@@ -2792,9 +2487,7 @@
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_overview_source = type === 'blackbox' ? false : dom.isOverviewSource.checked; const is_server_source = type === 'blackbox' ? false : dom.isServerSource.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');
@@ -2812,7 +2505,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, is_overview_source, is_detail_source, type }) body: JSON.stringify({ name, url, description, is_server_source, type })
}); });
if (response.ok) { if (response.ok) {

View File

@@ -23,86 +23,9 @@ 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;
@@ -241,10 +164,13 @@ 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;
@@ -257,16 +183,7 @@ 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;
let maxVal = niceMaxInUnit * unitFactor; const 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);
@@ -290,14 +207,14 @@ class AreaChart {
ctx.lineTo(p.left + chartW, y); ctx.lineTo(p.left + chartW, y);
ctx.stroke(); ctx.stroke();
// Y-axis labels // Y-axis labels - share the same unit for readability
const v = maxVal * (1 - i / gridLines); const valInUnit = niceMaxInUnit * (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';
const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + finalUnitLabel; // Format: "X.X MB/s" or "X MB/s"
const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + unitLabel;
ctx.fillText(label, p.left - 10, y + 3); ctx.fillText(label, p.left - 10, y + 3);
} }
@@ -310,42 +227,47 @@ 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);
// Draw data areas with clipping const getPVal = (arr, i) => (arr && i < arr.length) ? arr[i] : 0;
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)', '#6366f1', len); 'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)',
'#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)', '#06b6d4', len); 'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)',
'#06b6d4', len);
} }
ctx.restore();
// Draw P95 line // Draw P95 line
if (this.showP95 && this.p95 && (this.animProgress === 1 || this.isDraggingP95)) { if (this.showP95 && this.p95 && this.animProgress === 1) {
const p95Y = getY(this.p95); 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)'; ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)'; // --accent-rose
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

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

View File

@@ -2,10 +2,17 @@
* Database schema check * Database schema check
* Ensures required tables and columns exist at startup. * Ensures required tables and columns exist at startup.
*/ */
const path = require('path'); require('dotenv').config();
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const db = require('./db'); const db = require('./db');
const fs = require('fs');
const IS_SERVERLESS = [
process.env.SERVERLESS,
process.env.VERCEL,
process.env.AWS_LAMBDA_FUNCTION_NAME,
process.env.NETLIFY,
process.env.FUNCTION_TARGET,
process.env.K_SERVICE
].some(Boolean);
const SCHEMA = { const SCHEMA = {
users: { users: {
@@ -18,11 +25,7 @@ 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: `
@@ -32,30 +35,29 @@ 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: 'url', sql: "ALTER TABLE prometheus_sources ADD COLUMN url VARCHAR(500) NOT NULL AFTER name" }, name: 'is_server_source',
{ name: 'description', sql: "ALTER TABLE prometheus_sources ADD COLUMN description TEXT AFTER url" }, sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description"
{ name: 'is_server_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description" }, },
{ name: 'is_overview_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_overview_source TINYINT(1) DEFAULT 1 AFTER is_server_source" }, {
{ name: 'is_detail_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_detail_source TINYINT(1) DEFAULT 1 AFTER is_overview_source" }, name: 'type',
{ name: 'type', sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_detail_source" } sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_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 '数据可视化展示大屏', page_name VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
show_page_name TINYINT(1) DEFAULT 1, show_page_name TINYINT(1) DEFAULT 1,
title VARCHAR(255) DEFAULT '数据可视化展示大屏', title VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
logo_url TEXT, logo_url TEXT,
logo_url_dark TEXT, logo_url_dark TEXT,
favicon_url TEXT, favicon_url TEXT,
@@ -69,45 +71,65 @@ 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,
cdn_url VARCHAR(500) DEFAULT NULL,
prometheus_cache_ttl INT DEFAULT 30,
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
`, `,
seedSql: ` seedSql: `
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, prometheus_cache_ttl id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details
) VALUES ( ) VALUES (
1, '数据可视化展示大屏', 1, '数据可视化展示大屏', 'dark', 0, 'tx', 1, 30 1, 'Data Visualization Display Wall', 1, 'Data Visualization Display Wall', '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', sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name" }, name: 'show_page_name',
{ name: 'title', sql: "ALTER TABLE site_settings ADD COLUMN title VARCHAR(255) DEFAULT '数据可视化展示大屏' AFTER show_page_name" }, sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name"
{ name: 'logo_url', sql: "ALTER TABLE site_settings ADD COLUMN logo_url TEXT AFTER title" }, },
{ name: 'logo_url_dark', sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url" }, {
{ name: 'favicon_url', sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark" }, name: 'logo_url_dark',
{ name: 'default_theme', sql: "ALTER TABLE site_settings ADD COLUMN default_theme VARCHAR(20) DEFAULT 'dark' AFTER favicon_url" }, sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url"
{ name: 'show_95_bandwidth', sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme" }, },
{ name: 'p95_type', sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth" }, {
{ name: 'require_login_for_server_details', sql: "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type" }, name: 'favicon_url',
{ name: 'blackbox_source_id', sql: "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER require_login_for_server_details" }, sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark"
{ 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: 'show_95_bandwidth',
{ name: 'icp_filing', sql: "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target" }, sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme"
{ name: 'ps_filing', sql: "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing" }, },
{ name: 'show_server_ip', sql: "ALTER TABLE site_settings ADD COLUMN show_server_ip TINYINT(1) DEFAULT 0 AFTER ps_filing" }, {
{ name: 'ip_metric_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_metric_name VARCHAR(100) DEFAULT NULL AFTER show_server_ip" }, name: 'p95_type',
{ name: 'ip_label_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_label_name VARCHAR(100) DEFAULT 'address' AFTER ip_metric_name" }, sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"
{ name: 'custom_metrics', sql: "ALTER TABLE site_settings ADD COLUMN custom_metrics JSON DEFAULT NULL AFTER ip_label_name" }, },
{ name: 'cdn_url', sql: "ALTER TABLE site_settings ADD COLUMN cdn_url VARCHAR(500) DEFAULT NULL AFTER custom_metrics" }, {
{ name: 'prometheus_cache_ttl', sql: "ALTER TABLE site_settings ADD COLUMN prometheus_cache_ttl INT DEFAULT 30 AFTER cdn_url" } name: '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: {
@@ -122,12 +144,7 @@ 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: `
@@ -143,15 +160,7 @@ 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: `
@@ -164,75 +173,66 @@ 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 ensureTable(tableName, tableSchema) { async function addColumnIfMissing(tableName, existingColumns, column) {
if (existingColumns.has(column.name)) {
return;
}
try { try {
// 1. Ensure table exists
await db.query(tableSchema.createSql);
// 2. Check columns
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
const existingColumns = new Set(columns.map((column) => column.Field));
console.log(`[Database Integrity] Table '${tableName}' verified (${columns.length} columns)`);
for (const column of tableSchema.columns || []) {
if (!existingColumns.has(column.name)) {
console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`); console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`);
await db.query(column.sql); await db.query(column.sql);
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}'.`); 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) {
await db.query(tableSchema.createSql);
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
const existingColumns = new Set(columns.map((column) => column.Field));
for (const column of tableSchema.columns || []) {
await addColumnIfMissing(tableName, existingColumns, column);
}
// 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 db_migrate() { async function checkAndFixDatabase() {
console.log('[Database Integrity] Starting comprehensive database audit...'); const autoSchemaSync = process.env.DB_AUTO_SCHEMA_SYNC
? process.env.DB_AUTO_SCHEMA_SYNC === 'true'
: !IS_SERVERLESS;
const hasDbConfig = Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE);
// Try to check if we can even connect if (!hasDbConfig || !autoSchemaSync) {
try { return;
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] ❌ Audit failed:', err.message); console.error('[Database Integrity] Startup schema check failed:', err.message);
throw err;
} }
} }
module.exports = db_migrate; module.exports = checkAndFixDatabase;

View File

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

View File

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

View File

@@ -1,40 +1,102 @@
const path = require('path'); /**
require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); * Database Initialization Script
* Run: npm run init-db
* Creates the required MySQL database and tables.
*/
require('dotenv').config();
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const db_migrate = require('./db-schema-check');
const db = require('./db');
async function initDatabase() { async function initDatabase() {
const host = process.env.MYSQL_HOST || 'localhost';
const port = parseInt(process.env.MYSQL_PORT) || 3306;
const user = process.env.MYSQL_USER || 'root';
const password = process.env.MYSQL_PASSWORD || '';
const dbName = process.env.MYSQL_DATABASE || 'display_wall';
// 1. Create connection without database selected to create the DB itself
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host, host: process.env.MYSQL_HOST || 'localhost',
port, port: parseInt(process.env.MYSQL_PORT) || 3306,
user, user: process.env.MYSQL_USER || 'root',
password password: process.env.MYSQL_PASSWORD || ''
}); });
console.log('🔧 Initializing database environment...\n'); const dbName = process.env.MYSQL_DATABASE || 'display_wall';
console.log('🔧 Initializing database...\n');
// Create database // 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();
// 2. Re-initialize the standard pool so it can see the new DB await connection.query(`USE \`${dbName}\``);
db.initPool();
// 3. Use the centralized schema tool to create/fix all tables // Create users table
console.log(' 📦 Initializing tables using schema-check tool...'); await connection.query(`
await db_migrate(); CREATE TABLE IF NOT EXISTS users (
console.log(' ✅ Tables and columns ready'); id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
salt VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✅ Table "users" ready');
// Create prometheus_sources table
await connection.query(`
CREATE TABLE IF NOT EXISTS prometheus_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✅ Table "prometheus_sources" ready');
// Create site_settings table
await connection.query(`
CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY DEFAULT 1,
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
title VARCHAR(255) DEFAULT '数据可视化展示大屏',
logo_url TEXT,
default_theme VARCHAR(20) DEFAULT 'dark',
show_page_name TINYINT(1) DEFAULT 1,
logo_url_dark TEXT,
favicon_url TEXT,
show_95_bandwidth TINYINT(1) DEFAULT 0,
p95_type VARCHAR(20) DEFAULT 'tx',
require_login_for_server_details TINYINT(1) DEFAULT 1,
blackbox_source_id INT,
latency_source VARCHAR(100),
latency_dest VARCHAR(100),
latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// Insert default settings if not exists
await connection.query(`
INSERT IGNORE INTO site_settings (id, page_name, title, default_theme)
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark')
`);
console.log(' ✅ Table "site_settings" ready');
// Create server_locations table
await connection.query(`
CREATE TABLE IF NOT EXISTS server_locations (
id INT AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(255) NOT NULL UNIQUE,
country CHAR(2),
country_name VARCHAR(100),
region VARCHAR(100),
city VARCHAR(100),
latitude DOUBLE,
longitude DOUBLE,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✅ Table "server_locations" ready');
console.log('\n🎉 Database initialization complete!\n'); console.log('\n🎉 Database initialization complete!\n');
await connection.end();
} }
initDatabase().catch(err => { initDatabase().catch(err => {

View File

@@ -4,32 +4,17 @@ const db = require('./db');
const POLL_INTERVAL = 10000; // 10 seconds const POLL_INTERVAL = 10000; // 10 seconds
async function pollLatency() { async function resolveBlackboxLatency(route) {
try {
const [routes] = await db.query(`
SELECT r.*, s.url
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
WHERE s.type = 'blackbox'
`);
if (routes.length === 0) return;
// Poll each route
await Promise.allSettled(routes.map(async (route) => {
try {
// Blackbox exporter probe URL // Blackbox exporter probe URL
// We assume ICMP module for now. If target is a URL, maybe use http_2xx // We assume ICMP module for now. If target is a URL, maybe use http_2xx
let module = 'icmp'; let module = 'icmp';
let target = route.latency_target; const target = route.latency_target;
if (target.startsWith('http://') || target.startsWith('https://')) { if (target.startsWith('http://') || target.startsWith('https://')) {
module = 'http_2xx'; module = 'http_2xx';
} }
const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`; const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`;
const startTime = Date.now();
const response = await axios.get(probeUrl, { const response = await axios.get(probeUrl, {
timeout: 5000, timeout: 5000,
responseType: 'text', responseType: 'text',
@@ -101,16 +86,48 @@ async function pollLatency() {
// 3. Final decision // 3. Final decision
// If it's a success, use found latency. If success=0 or missing, handle carefully. // If it's a success, use found latency. If success=0 or missing, handle carefully.
let latency;
if (isProbeSuccess && foundLatency !== null) { if (isProbeSuccess && foundLatency !== null) {
latency = foundLatency; return foundLatency;
} else {
// If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
latency = null;
} }
// Save to Valkey // If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
return null;
}
async function resolveLatencyForRoute(route) {
try {
if (route.source_type === 'blackbox' || route.type === 'blackbox') {
const latency = await resolveBlackboxLatency(route);
if (route.id !== undefined) {
await cache.set(`latency:route:${route.id}`, latency, 60); await cache.set(`latency:route:${route.id}`, latency, 60);
}
return latency;
}
return null;
} catch (err) {
if (route.id !== undefined) {
await cache.set(`latency:route:${route.id}`, null, 60);
}
return null;
}
}
async function pollLatency() {
try {
const [routes] = await db.query(`
SELECT r.*, s.url
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
WHERE s.type = 'blackbox'
`);
if (routes.length === 0) return;
// Poll each route
await Promise.allSettled(routes.map(async (route) => {
try {
await resolveLatencyForRoute({ ...route, source_type: 'blackbox' });
} catch (err) { } catch (err) {
await cache.set(`latency:route:${route.id}`, null, 60); await cache.set(`latency:route:${route.id}`, null, 60);
} }
@@ -130,5 +147,7 @@ function start() {
} }
module.exports = { module.exports = {
pollLatency,
resolveLatencyForRoute,
start start
}; };

View File

@@ -1,46 +1,22 @@
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');
const crypto = require('crypto');
const QUERY_TIMEOUT = 10000; const QUERY_TIMEOUT = 10000;
function getCacheKey(type, baseUrl, expr, extra = '') { // Reusable agents to handle potential redirect issues and protocol mismatches
return `prom_${type}:${crypto.createHash('md5').update(`${baseUrl}:${expr}:${extra}`).digest('hex')}`; 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, lastSeen } const serverIdMap = new Map(); // token -> { instance, job, source }
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', getSecret()) const hash = crypto.createHmac('sha256', SECRET)
.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;
} }
@@ -72,12 +48,12 @@ function createClient(baseUrl) {
/** /**
* Test Prometheus connection * Test Prometheus connection
*/ */
async function testConnection(url, customTimeout = null) { async function testConnection(url) {
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(), customTimeout || QUERY_TIMEOUT); const timer = setTimeout(() => controller.abort(), 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`, {
@@ -212,11 +188,7 @@ async function getOverviewMetrics(url, sourceName) {
diskFreeResult, diskFreeResult,
netRxResult, netRxResult,
netTxResult, netTxResult,
netRx24hResult, targetsResult
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(() => []),
@@ -234,16 +206,8 @@ async function getOverviewMetrics(url, sourceName) {
query(url, 'sum by (instance, job) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []), 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
@@ -258,10 +222,7 @@ 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, lastSeen: Date.now() }); serverIdMap.set(token, { instance: originalInstance, source: sourceName, job });
// 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, {
@@ -277,14 +238,9 @@ 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);
@@ -350,26 +306,6 @@ 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;
@@ -377,7 +313,6 @@ 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());
@@ -456,23 +391,25 @@ function calculateTrafficFromHistory(values) {
} }
/** /**
* Get total traffic for the past 24h using Prometheus increase() for stability and accuracy * Get total traffic for the past 24h by fetching all points and integrating
*/ */
async function get24hTrafficSum(url) { async function get24hTrafficSum(url) {
try { const now = Math.floor(Date.now() / 1000);
const start = now - 86400;
const step = 60; // 1-minute points for calculation
const [rxResult, txResult] = await Promise.all([ const [rxResult, txResult] = await Promise.all([
query(url, 'sum(increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []), 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_transmit_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(() => [])
]); ]);
const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0; const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0; const txValues = txResult.length > 0 ? txResult[0].values : [];
return { rx, tx }; return {
} catch (err) { rx: calculateTrafficFromHistory(rxValues),
console.error(`[Prometheus] get24hTrafficSum error:`, err.message); tx: calculateTrafficFromHistory(txValues)
return { rx: 0, tx: 0 }; };
}
} }
/** /**
@@ -480,28 +417,34 @@ 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(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 rxExpr = `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`;
const txExpr = `sum(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 txExpr = `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`;
const [rxResult, txResult] = await Promise.all([ const [rxResult, txResult] = await Promise.all([
query(url, rxExpr).catch(() => []), queryRange(url, rxExpr, start, now, step).catch(() => []),
query(url, txExpr).catch(() => []) queryRange(url, txExpr, start, now, step).catch(() => [])
]); ]);
const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0; const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0; const txValues = txResult.length > 0 ? txResult[0].values : [];
return { rx, tx }; return {
rx: calculateTrafficFromHistory(rxValues),
tx: calculateTrafficFromHistory(txValues)
};
} }
/** /**
* Get network traffic history (past 24h, 5-min intervals for chart) * Get network traffic history (past 24h, 5-min intervals for chart)
*/ */
async function getNetworkHistory(url) { async function getNetworkHistory(url) {
const step = 300; // 5 minutes for better resolution on chart const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
const start = now - 86400; // 24h ago const start = now - 86400; // 24h ago
const step = 300; // 5 minutes for better resolution on chart
const [rxResult, txResult] = await Promise.all([ const [rxResult, txResult] = await Promise.all([
queryRange(url, queryRange(url,
@@ -553,9 +496,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 step = 60; // 1 minute const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
const start = now - 3600; // 1h ago const start = now - 3600; // 1h ago
const step = 60; // 1 minute
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)',
@@ -590,22 +533,19 @@ function mergeCpuHistories(histories) {
} }
async function resolveToken(token) { 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, settings = {}) { async function getServerDetails(baseUrl, instance, job) {
const url = normalizeUrl(baseUrl); const url = normalizeUrl(baseUrl);
const node = await resolveToken(instance); const node = resolveToken(instance);
// Queries based on the requested dashboard structure // Queries based on the requested dashboard structure
const queries = { const queries = {
@@ -625,9 +565,6 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) {
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`, 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/.*"}`
@@ -653,85 +590,6 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) {
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 => {
@@ -772,7 +630,7 @@ async function getServerDetails(baseUrl, instance, job, settings = {}) {
*/ */
async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') { 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 = await resolveToken(instance); const node = resolveToken(instance);
// CPU Busy history: 100 - idle // CPU Busy history: 100 - idle
if (metric === 'cpuBusy') { if (metric === 'cpuBusy') {
@@ -797,8 +655,7 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st
netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`, 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);
@@ -932,8 +789,10 @@ module.exports = {
getLatency: async (blackboxUrl, target) => { getLatency: async (blackboxUrl, target) => {
if (!blackboxUrl || !target) return null; if (!blackboxUrl || !target) return null;
try { try {
const normalized = normalizeUrl(blackboxUrl); const normalized = blackboxUrl.trim().replace(/\/+$/, '');
// Construct a single optimized query searching for priority metrics and common labels
// Prioritize probe_icmp_duration_seconds OVER probe_duration_seconds
const queryExpr = `( 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
@@ -945,15 +804,19 @@ module.exports = {
probe_duration_seconds{target="${target}"} probe_duration_seconds{target="${target}"}
)`; )`;
const result = await query(normalized, queryExpr); const params = new URLSearchParams({ query: queryExpr });
if (result && result.length > 0) { const res = await fetch(`${normalized}/api/v1/query?${params.toString()}`);
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) {
console.error(`[Prometheus] Error fetching latency for ${target}:`, err.message); console.error(`[Prometheus] Error fetching latency for ${target}:`, err.message);
return null; return null;
} }
}, }
getCacheKey
}; };

43
vercel.json Normal file
View File

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