Compare commits
39 Commits
serverless
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba633c8be4 | ||
|
|
44843475c8 | ||
|
|
5e9dac6197 | ||
|
|
77743ce097 | ||
|
|
28667bcbee | ||
|
|
395b3f3c4e | ||
|
|
0c3ef3d07e | ||
|
|
83e8a96b2b | ||
|
|
0a8efaf29d | ||
|
|
34bf4d5e91 | ||
|
|
24b5f8455e | ||
|
|
e60fa2b982 | ||
|
|
b79cb09987 | ||
|
|
d7ac1bedb4 | ||
|
|
a876c854f4 | ||
|
|
18c81fb8bb | ||
|
|
d8c33ca3ee | ||
|
|
27c2fb0b95 | ||
|
|
bdc723d197 | ||
|
|
531913a83c | ||
|
|
b7e7c173d7 | ||
|
|
14796231f1 | ||
|
|
1826d4e34a | ||
|
|
e373e1ce62 | ||
|
|
4467b62a01 | ||
|
|
3519003a77 | ||
|
|
7362bcf206 | ||
|
|
2cd6c6ef27 | ||
|
|
cb27d1a249 | ||
|
|
710b6a719e | ||
|
|
66b5702d03 | ||
|
|
90634dfacf | ||
|
|
c9784ec48e | ||
|
|
cf1842f4e5 | ||
|
|
f1a215d504 | ||
|
|
9beba2a306 | ||
|
|
a3340cb630 | ||
|
|
5afcd3d86a | ||
|
|
e65de2c30b |
@@ -328,6 +328,33 @@ body {
|
|||||||
transform: rotate(30deg);
|
transform: rotate(30deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Global Refresh Button ---- */
|
||||||
|
.btn-refresh-global {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh-global:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh-global svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Theme Switch ---- */
|
/* ---- Theme Switch ---- */
|
||||||
.theme-switch-wrapper {
|
.theme-switch-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1458,7 +1485,7 @@ input:checked+.slider:before {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
z-index: 1000;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2751,7 +2778,21 @@ input:checked+.slider:before {
|
|||||||
.filings {
|
.filings {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filing-sep {
|
||||||
|
display: none;
|
||||||
|
width: 1px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.filing-sep {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filings a {
|
.filings a {
|
||||||
@@ -2789,3 +2830,84 @@ input:checked+.slider:before {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Source Settings Toggles ---- */
|
||||||
|
/* ---- Source Settings Toggles ---- */
|
||||||
|
.source-options-clean-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-option-item:hover .source-option-label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-option-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 38px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
transition: .35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
transition: .35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input:checked + .switch-label {
|
||||||
|
background-color: rgba(99, 102, 241, 0.15);
|
||||||
|
border-color: var(--accent-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input:checked + .switch-label:before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background-color: var(--accent-indigo);
|
||||||
|
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme support */
|
||||||
|
:root.light-theme .switch-label:before {
|
||||||
|
background-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,20 @@
|
|||||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Global Error Logger for remote debugging
|
||||||
|
window.onerror = function(msg, url, line, col, error) {
|
||||||
|
var debugDiv = document.getElementById('js-debug-overlay');
|
||||||
|
if (!debugDiv) {
|
||||||
|
debugDiv = document.createElement('div');
|
||||||
|
debugDiv.id = 'js-debug-overlay';
|
||||||
|
debugDiv.style.cssText = 'position:fixed;top:0;left:0;width:100%;background:rgba(220,38,38,0.95);color:white;z-index:99999;padding:10px;font-family:monospace;font-size:12px;max-height:30vh;overflow:auto;pointer-events:none;';
|
||||||
|
document.body.appendChild(debugDiv);
|
||||||
|
}
|
||||||
|
debugDiv.innerHTML += '<div>[JS ERROR] ' + msg + ' at ' + line + ':' + col + '</div>';
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -141,6 +155,13 @@
|
|||||||
<div id="userSection">
|
<div id="userSection">
|
||||||
<button class="btn btn-login" id="btnLogin">登录</button>
|
<button class="btn btn-login" id="btnLogin">登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-refresh-global" id="btnGlobalRefresh" title="全局强制刷新数据" style="display: none;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<polyline points="1 20 1 14 7 14" />
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="btn-settings" id="btnSettings" title="配置管理">
|
<button class="btn-settings" id="btnSettings" title="配置管理">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
stroke-linejoin="round">
|
stroke-linejoin="round">
|
||||||
@@ -378,11 +399,13 @@
|
|||||||
<th class="sortable" data-sort="disk">磁盘 <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="disk">磁盘 <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="netRx">网络 ↓ <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="netRx">网络 ↓ <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="netTx">网络 ↑ <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="netTx">网络 ↑ <span class="sort-icon"></span></th>
|
||||||
|
<th class="sortable" data-sort="conntrack">Conntrack <span class="sort-icon"></span></th>
|
||||||
|
<th class="sortable" data-sort="traffic24h">24h 流量 <span class="sort-icon"></span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="serverTableBody">
|
<tbody id="serverTableBody">
|
||||||
<tr class="empty-row">
|
<tr class="empty-row">
|
||||||
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
|
<td colspan="10">暂无数据 - 请先配置 Prometheus 数据源</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -412,9 +435,9 @@
|
|||||||
<div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div>
|
<div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div>
|
||||||
<div class="filings">
|
<div class="filings">
|
||||||
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank" id="psFilingDisplay" style="display: none;">
|
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank" id="psFilingDisplay" style="display: none;">
|
||||||
<img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b1220'/%3E%3Cpath d='M32 10l18 8v12c0 11.6-7.2 21.9-18 26-10.8-4.1-18-14.4-18-26V18l18-8z' fill='%2310b981'/%3E%3Cpath d='M32 18l10 4.6v7.1c0 7-4.1 13.4-10 16.1-5.9-2.7-10-9.1-10-16.1v-7.1L32 18z' fill='%23ecfdf5'/%3E%3Cpath d='M28 31.5l3 3 6-6' fill='none' stroke='%2310b981' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" alt="公安备案" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 2px;">
|
|
||||||
<span id="psFilingText"></span>
|
<span id="psFilingText"></span>
|
||||||
</a>
|
</a>
|
||||||
|
<span class="filing-sep"></span>
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,6 +450,7 @@
|
|||||||
<div class="modal-tabs">
|
<div class="modal-tabs">
|
||||||
<button class="modal-tab active" data-tab="prom">数据源管理</button>
|
<button class="modal-tab active" data-tab="prom">数据源管理</button>
|
||||||
<button class="modal-tab" data-tab="site">大屏设置</button>
|
<button class="modal-tab" data-tab="site">大屏设置</button>
|
||||||
|
<button class="modal-tab" data-tab="security">安全设置</button>
|
||||||
<button class="modal-tab" data-tab="latency">延迟线路管理</button>
|
<button class="modal-tab" data-tab="latency">延迟线路管理</button>
|
||||||
<button class="modal-tab" data-tab="auth">账号安全</button>
|
<button class="modal-tab" data-tab="auth">账号安全</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,17 +483,31 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group form-group-wide">
|
<div class="form-group form-group-wide">
|
||||||
<label for="sourceDesc">描述 (可选)</label>
|
<label for="sourceDesc">描述 (可选)</label>
|
||||||
<input type="text" id="sourceDesc" placeholder="数据源描述" autocomplete="off">
|
<input type="text" id="sourceDesc" placeholder="记录关于此数据源的备注信息" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="serverSourceOption"
|
</div>
|
||||||
style="display: flex; align-items: flex-end; padding-bottom: 8px;">
|
<div class="form-row" id="serverSourceOption" style="margin-top: 4px;">
|
||||||
<label
|
<div class="form-group form-group-wide">
|
||||||
style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
|
<div class="source-options-clean-row">
|
||||||
<input type="checkbox" id="isServerSource" checked
|
<label class="source-option-item" title="将此数据源的服务器指标聚合到首页总览中">
|
||||||
style="width: 16px; height: 16px; accent-color: var(--accent-indigo);">
|
<div class="switch-wrapper">
|
||||||
<span>用于服务器展示</span>
|
<input type="checkbox" id="isOverviewSource" checked class="switch-input">
|
||||||
</label>
|
<div class="switch-label"></div>
|
||||||
|
</div>
|
||||||
|
<span class="source-option-label">加入总览统计</span>
|
||||||
|
</label>
|
||||||
|
<label class="source-option-item" title="在服务器详情列表中显示此数据源的服务器">
|
||||||
|
<div class="switch-wrapper">
|
||||||
|
<input type="checkbox" id="isDetailSource" checked class="switch-input">
|
||||||
|
<div class="switch-label"></div>
|
||||||
|
</div>
|
||||||
|
<span class="source-option-label">加入详情展示</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="isServerSource" checked disabled style="display: none;">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 8px;">
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-test" id="btnTest">测试连接</button>
|
<button class="btn btn-test" id="btnTest">测试连接</button>
|
||||||
<button class="btn btn-add" id="btnAdd">添加</button>
|
<button class="btn btn-add" id="btnAdd">添加</button>
|
||||||
@@ -507,15 +545,6 @@
|
|||||||
<option value="0">隐藏 (Hide)</option>
|
<option value="0">隐藏 (Hide)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top: 15px;">
|
|
||||||
<label for="requireLoginForServerDetailsInput">服务器详情是否仅登录后可查看</label>
|
|
||||||
<select id="requireLoginForServerDetailsInput"
|
|
||||||
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
|
|
||||||
<option value="1">仅登录后可查看</option>
|
|
||||||
<option value="0">允许公开查看</option>
|
|
||||||
</select>
|
|
||||||
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,未登录访客仍可看到大屏总览,但点击单台服务器时需要先登录。</p>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-top: 15px;">
|
<div class="form-group" style="margin-top: 15px;">
|
||||||
<label for="logoUrlInput">Logo URL (白天/默认,支持图片链接)</label>
|
<label for="logoUrlInput">Logo URL (白天/默认,支持图片链接)</label>
|
||||||
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo_light.png">
|
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo_light.png">
|
||||||
@@ -574,6 +603,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Settings Tab -->
|
||||||
|
<div class="tab-content" id="tab-security">
|
||||||
|
<div class="security-settings-form">
|
||||||
|
<h3>安全与隐私设置</h3>
|
||||||
|
<div class="form-group" style="margin-top: 15px;">
|
||||||
|
<label for="requireLoginForServerDetailsInput">服务器详情是否仅登录后可查看</label>
|
||||||
|
<select id="requireLoginForServerDetailsInput"
|
||||||
|
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
|
||||||
|
<option value="1">仅登录后可查看</option>
|
||||||
|
<option value="0">允许公开查看</option>
|
||||||
|
</select>
|
||||||
|
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,未登录访客仍可看到大屏总览,但点击单台服务器时需要先登录。</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 15px;">
|
||||||
|
<label for="showServerIpInput">是否在服务器详情中显示公网 IP</label>
|
||||||
|
<select id="showServerIpInput"
|
||||||
|
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
|
||||||
|
<option value="1">显示 (Show)</option>
|
||||||
|
<option value="0">隐藏 (Hide)</option>
|
||||||
|
</select>
|
||||||
|
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,点击服务器详情时会显示该服务器的公网 IP 地址。</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 15px;">
|
||||||
|
<label for="ipMetricNameInput">自定义 IP 采集指标 (可选)</label>
|
||||||
|
<input type="text" id="ipMetricNameInput" placeholder="例:node_network_address_info">
|
||||||
|
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">如果您的 Prometheus 中有专门记录 IP 的指标,请在此输入。留空则尝试自动发现。</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 15px;">
|
||||||
|
<label for="ipLabelNameInput">IP 指标中的 Label 名称</label>
|
||||||
|
<input type="text" id="ipLabelNameInput" placeholder="默认:address">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-add" id="btnSaveSecuritySettings">保存安全设置</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-message" id="securitySettingsMessage"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Detail Metrics Tab -->
|
||||||
|
<div class="tab-content" id="tab-details-metrics">
|
||||||
|
<div class="metrics-settings-form">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h3 style="margin: 0;">服务器详情指标配置</h3>
|
||||||
|
<button class="btn btn-add" id="btnAddCustomMetric" style="padding: 6px 12px; font-size: 0.8rem;">
|
||||||
|
<i class="fas fa-plus"></i> 添加指标
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="customMetricsList" class="custom-metrics-list" style="max-height: 400px; overflow-y: auto; padding-right: 5px;">
|
||||||
|
<!-- Dynamic rows will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-add" id="btnSaveCustomMetrics">保存指标配置</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-message" id="customMetricsMessage"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Latency Routes Tab -->
|
<!-- Latency Routes Tab -->
|
||||||
<div class="tab-content" id="tab-latency">
|
<div class="tab-content" id="tab-latency">
|
||||||
<div class="latency-settings-form">
|
<div class="latency-settings-form">
|
||||||
@@ -589,6 +677,7 @@
|
|||||||
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);">
|
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);">
|
||||||
<option value="">-- 选择数据源 --</option>
|
<option value="">-- 选择数据源 --</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>起航点</label>
|
<label>起航点</label>
|
||||||
|
|||||||
547
public/js/app.js
547
public/js/app.js
@@ -37,6 +37,8 @@
|
|||||||
sourceDesc: document.getElementById('sourceDesc'),
|
sourceDesc: document.getElementById('sourceDesc'),
|
||||||
btnTest: document.getElementById('btnTest'),
|
btnTest: document.getElementById('btnTest'),
|
||||||
btnAdd: document.getElementById('btnAdd'),
|
btnAdd: document.getElementById('btnAdd'),
|
||||||
|
isOverviewSource: document.getElementById('isOverviewSource'),
|
||||||
|
isDetailSource: document.getElementById('isDetailSource'),
|
||||||
isServerSource: document.getElementById('isServerSource'),
|
isServerSource: document.getElementById('isServerSource'),
|
||||||
formMessage: document.getElementById('formMessage'),
|
formMessage: document.getElementById('formMessage'),
|
||||||
sourceItems: document.getElementById('sourceItems'),
|
sourceItems: document.getElementById('sourceItems'),
|
||||||
@@ -52,7 +54,10 @@
|
|||||||
siteTitleInput: document.getElementById('siteTitleInput'),
|
siteTitleInput: document.getElementById('siteTitleInput'),
|
||||||
logoUrlInput: document.getElementById('logoUrlInput'),
|
logoUrlInput: document.getElementById('logoUrlInput'),
|
||||||
btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'),
|
btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'),
|
||||||
|
btnSaveSecuritySettings: document.getElementById('btnSaveSecuritySettings'),
|
||||||
siteSettingsMessage: document.getElementById('siteSettingsMessage'),
|
siteSettingsMessage: document.getElementById('siteSettingsMessage'),
|
||||||
|
securitySettingsMessage: document.getElementById('securitySettingsMessage'),
|
||||||
|
customMetricsMessage: document.getElementById('customMetricsMessage'),
|
||||||
logoText: document.getElementById('logoText'),
|
logoText: document.getElementById('logoText'),
|
||||||
logoIconContainer: document.getElementById('logoIconContainer'),
|
logoIconContainer: document.getElementById('logoIconContainer'),
|
||||||
defaultThemeInput: document.getElementById('defaultThemeInput'),
|
defaultThemeInput: document.getElementById('defaultThemeInput'),
|
||||||
@@ -112,13 +117,21 @@
|
|||||||
globeCard: document.getElementById('globeCard'),
|
globeCard: document.getElementById('globeCard'),
|
||||||
btnExpandGlobe: document.getElementById('btnExpandGlobe'),
|
btnExpandGlobe: document.getElementById('btnExpandGlobe'),
|
||||||
btnRefreshNetwork: document.getElementById('btnRefreshNetwork'),
|
btnRefreshNetwork: document.getElementById('btnRefreshNetwork'),
|
||||||
|
btnGlobalRefresh: document.getElementById('btnGlobalRefresh'),
|
||||||
|
showServerIpInput: document.getElementById('showServerIpInput'),
|
||||||
|
ipMetricNameInput: document.getElementById('ipMetricNameInput'),
|
||||||
|
ipLabelNameInput: document.getElementById('ipLabelNameInput'),
|
||||||
// Footer & Filing
|
// Footer & Filing
|
||||||
icpFilingInput: document.getElementById('icpFilingInput'),
|
icpFilingInput: document.getElementById('icpFilingInput'),
|
||||||
psFilingInput: document.getElementById('psFilingInput'),
|
psFilingInput: document.getElementById('psFilingInput'),
|
||||||
icpFilingDisplay: document.getElementById('icpFilingDisplay'),
|
icpFilingDisplay: document.getElementById('icpFilingDisplay'),
|
||||||
psFilingDisplay: document.getElementById('psFilingDisplay'),
|
psFilingDisplay: document.getElementById('psFilingDisplay'),
|
||||||
psFilingText: document.getElementById('psFilingText'),
|
psFilingText: document.getElementById('psFilingText'),
|
||||||
copyrightYear: document.getElementById('copyrightYear')
|
copyrightYear: document.getElementById('copyrightYear'),
|
||||||
|
customMetricsList: document.getElementById('customMetricsList'),
|
||||||
|
btnAddCustomMetric: document.getElementById('btnAddCustomMetric'),
|
||||||
|
btnSaveCustomMetrics: document.getElementById('btnSaveCustomMetrics'),
|
||||||
|
customDataContainer: document.getElementById('customDataContainer')
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- State ----
|
// ---- State ----
|
||||||
@@ -153,6 +166,8 @@
|
|||||||
|
|
||||||
let myMap2D = null;
|
let myMap2D = null;
|
||||||
let editingRouteId = null;
|
let editingRouteId = null;
|
||||||
|
let allStoredSources = [];
|
||||||
|
let allStoredLatencyRoutes = [];
|
||||||
|
|
||||||
async function fetchJsonWithFallback(urls) {
|
async function fetchJsonWithFallback(urls) {
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
@@ -172,11 +187,77 @@
|
|||||||
throw lastError || new Error('All JSON sources failed');
|
throw lastError || new Error('All JSON sources failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Custom Metrics Helpers ----
|
||||||
|
function addMetricRow(config = {}) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'metric-row';
|
||||||
|
row.style = 'background: rgba(255,255,255,0.03); padding: 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border-color);';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 10px; margin-bottom: 8px;">
|
||||||
|
<input type="text" placeholder="显示名 (如:内核)" class="metric-display-name" value="${config.name || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
|
||||||
|
<input type="text" placeholder="指标名 (PromQL)" class="metric-query" value="${config.metric || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
|
||||||
|
<input type="text" placeholder="取值标签" class="metric-label" value="${config.label || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<label style="font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; cursor: pointer;">
|
||||||
|
<input type="checkbox" class="metric-is-ip" ${config.is_ip ? 'checked' : ''} style="margin-right: 5px;"> 设为 IP 发现源
|
||||||
|
</label>
|
||||||
|
<button class="btn-remove-metric" style="background: none; border: none; color: #ff4d4f; cursor: pointer; font-size: 0.75rem;">
|
||||||
|
<i class="fas fa-trash"></i> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.querySelector('.btn-remove-metric').onclick = () => row.remove();
|
||||||
|
dom.customMetricsList.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCustomMetricsUI(metrics) {
|
||||||
|
if (!dom.customMetricsList) return;
|
||||||
|
dom.customMetricsList.innerHTML = '';
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
list = typeof metrics === 'string' ? JSON.parse(metrics) : (metrics || []);
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
list.forEach(m => addMetricRow(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
// Add a placeholder/default row if empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomMetricsFromUI() {
|
||||||
|
const rows = dom.customMetricsList.querySelectorAll('.metric-row');
|
||||||
|
const metrics = [];
|
||||||
|
rows.forEach(row => {
|
||||||
|
const name = row.querySelector('.metric-display-name').value.trim();
|
||||||
|
const metric = row.querySelector('.metric-query').value.trim();
|
||||||
|
const label = row.querySelector('.metric-label').value.trim();
|
||||||
|
const is_ip = row.querySelector('.metric-is-ip').checked;
|
||||||
|
if (metric) {
|
||||||
|
metrics.push({ name, metric, label, is_ip });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Initialize ----
|
// ---- Initialize ----
|
||||||
function init() {
|
function init() {
|
||||||
|
try {
|
||||||
|
console.log('[Init] Start...');
|
||||||
|
|
||||||
|
// Clear existing intervals to prevent duplication on re-init
|
||||||
|
if (backgroundIntervals && backgroundIntervals.length > 0) {
|
||||||
|
backgroundIntervals.forEach(clearInterval);
|
||||||
|
}
|
||||||
|
backgroundIntervals = [];
|
||||||
|
|
||||||
// Resource Gauges Time
|
// Resource Gauges Time
|
||||||
updateGaugesTime();
|
updateGaugesTime();
|
||||||
setInterval(updateGaugesTime, 1000);
|
backgroundIntervals.push(setInterval(updateGaugesTime, 1000));
|
||||||
|
|
||||||
// Initial footer year
|
// Initial footer year
|
||||||
if (dom.copyrightYear) {
|
if (dom.copyrightYear) {
|
||||||
@@ -202,7 +283,7 @@
|
|||||||
dom.serverSourceOption.style.display = 'none';
|
dom.serverSourceOption.style.display = 'none';
|
||||||
dom.isServerSource.checked = false;
|
dom.isServerSource.checked = false;
|
||||||
} else {
|
} else {
|
||||||
dom.serverSourceOption.style.display = 'flex';
|
dom.serverSourceOption.style.display = '';
|
||||||
dom.isServerSource.checked = true;
|
dom.isServerSource.checked = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -212,49 +293,78 @@
|
|||||||
dom.btnCancelEditRoute.onclick = cancelEditRoute;
|
dom.btnCancelEditRoute.onclick = cancelEditRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
dom.settingsModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === dom.settingsModal) closeSettings();
|
|
||||||
});
|
|
||||||
dom.btnTest.addEventListener('click', testConnection);
|
|
||||||
dom.btnAdd.addEventListener('click', addSource);
|
|
||||||
|
|
||||||
// Auth & Theme listeners
|
// Auth & Theme listeners
|
||||||
dom.themeToggle.addEventListener('change', toggleTheme);
|
if (dom.themeToggle) dom.themeToggle.addEventListener('change', toggleTheme);
|
||||||
|
if (dom.btnSettings) dom.btnSettings.addEventListener('click', openSettings);
|
||||||
// System Theme Listener (Real-time)
|
if (dom.modalClose) dom.modalClose.addEventListener('click', closeSettings);
|
||||||
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: light)');
|
if (dom.btnTest) dom.btnTest.addEventListener('click', testConnection);
|
||||||
if (systemThemeMedia.addEventListener) {
|
if (dom.btnAdd) dom.btnAdd.addEventListener('click', addSource);
|
||||||
systemThemeMedia.addEventListener('change', () => {
|
|
||||||
const savedTheme = localStorage.getItem('theme') || (window.SITE_SETTINGS && window.SITE_SETTINGS.default_theme) || 'dark';
|
// Auth & Login
|
||||||
if (savedTheme === 'auto') {
|
if (dom.btnLogin) dom.btnLogin.addEventListener('click', openLoginModal);
|
||||||
applyTheme('auto');
|
if (dom.closeLoginModal) dom.closeLoginModal.addEventListener('click', closeLoginModal);
|
||||||
}
|
if (dom.loginForm) dom.loginForm.addEventListener('submit', handleLogin);
|
||||||
|
if (dom.loginModal) {
|
||||||
|
dom.loginModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === dom.loginModal) closeLoginModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dom.btnLogin.addEventListener('click', openLoginModal);
|
|
||||||
dom.closeLoginModal.addEventListener('click', closeLoginModal);
|
|
||||||
dom.loginForm.addEventListener('submit', handleLogin);
|
|
||||||
dom.loginModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === dom.loginModal) closeLoginModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
dom.modalTabs.forEach(tab => {
|
if (dom.modalTabs) {
|
||||||
tab.addEventListener('click', () => {
|
dom.modalTabs.forEach(tab => {
|
||||||
const targetTab = tab.getAttribute('data-tab');
|
tab.addEventListener('click', () => {
|
||||||
switchTab(targetTab);
|
const targetTab = tab.getAttribute('data-tab');
|
||||||
|
switchTab(targetTab);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Site settings
|
// Site settings
|
||||||
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
|
if (dom.btnSaveSiteSettings) {
|
||||||
dom.btnAddRoute.addEventListener('click', addLatencyRoute);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// Auth password change
|
// Auth password change
|
||||||
if (dom.btnChangePassword) {
|
if (dom.btnChangePassword) {
|
||||||
dom.btnChangePassword.addEventListener('click', saveChangePassword);
|
dom.btnChangePassword.addEventListener('click', saveChangePassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings management event delegation
|
||||||
|
if (dom.sourceItems) {
|
||||||
|
dom.sourceItems.addEventListener('click', (e) => {
|
||||||
|
const btnEdit = e.target.closest('.btn-edit-source');
|
||||||
|
const btnDelete = e.target.closest('.btn-delete-source');
|
||||||
|
if (btnEdit) {
|
||||||
|
window.editSource(parseInt(btnEdit.getAttribute('data-id'), 10));
|
||||||
|
} else if (btnDelete) {
|
||||||
|
window.deleteSource(parseInt(btnDelete.getAttribute('data-id'), 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dom.latencyRoutesList) {
|
||||||
|
dom.latencyRoutesList.addEventListener('click', (e) => {
|
||||||
|
const btnEdit = e.target.closest('.btn-edit-route');
|
||||||
|
const btnDelete = e.target.closest('.btn-delete-route');
|
||||||
|
if (btnEdit) {
|
||||||
|
window.editRoute(parseInt(btnEdit.getAttribute('data-id'), 10));
|
||||||
|
} else if (btnDelete) {
|
||||||
|
window.deleteLatencyRoute(parseInt(btnDelete.getAttribute('data-id'), 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Globe expansion (FLIP animation via Web Animations API)
|
// Globe expansion (FLIP animation via Web Animations API)
|
||||||
let savedGlobeRect = null;
|
let savedGlobeRect = null;
|
||||||
let globeAnimating = false;
|
let globeAnimating = false;
|
||||||
@@ -374,23 +484,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGlobalRefresh = async (btn) => {
|
||||||
|
const icon = btn.querySelector('svg');
|
||||||
|
if (icon) icon.style.animation = 'spin 0.8s ease-in-out';
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fetchNetworkHistory(true),
|
||||||
|
fetchMetrics(true)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
setTimeout(() => {
|
||||||
|
icon.style.animation = '';
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (dom.btnRefreshNetwork) {
|
if (dom.btnRefreshNetwork) {
|
||||||
dom.btnRefreshNetwork.addEventListener('click', async () => {
|
dom.btnRefreshNetwork.addEventListener('click', () => handleGlobalRefresh(dom.btnRefreshNetwork));
|
||||||
const icon = dom.btnRefreshNetwork.querySelector('svg');
|
}
|
||||||
if (icon) icon.style.animation = 'spin 0.8s ease-in-out';
|
|
||||||
|
if (dom.btnGlobalRefresh) {
|
||||||
// Force refresh all Prometheus 24h data and overview
|
dom.btnGlobalRefresh.addEventListener('click', () => handleGlobalRefresh(dom.btnGlobalRefresh));
|
||||||
await Promise.all([
|
|
||||||
fetchNetworkHistory(true),
|
|
||||||
fetchMetrics(true)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (icon) {
|
|
||||||
setTimeout(() => {
|
|
||||||
icon.style.animation = '';
|
|
||||||
}, 800);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcut
|
// Keyboard shortcut
|
||||||
@@ -538,15 +653,36 @@
|
|||||||
if (dom.psFilingInput) dom.psFilingInput.value = window.SITE_SETTINGS.ps_filing || '';
|
if (dom.psFilingInput) dom.psFilingInput.value = window.SITE_SETTINGS.ps_filing || '';
|
||||||
if (dom.logoUrlDarkInput) dom.logoUrlDarkInput.value = window.SITE_SETTINGS.logo_url_dark || '';
|
if (dom.logoUrlDarkInput) dom.logoUrlDarkInput.value = window.SITE_SETTINGS.logo_url_dark || '';
|
||||||
if (dom.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || '';
|
if (dom.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || '';
|
||||||
// Latency routes loaded separately in openSettings or on startup
|
if (dom.showServerIpInput) dom.showServerIpInput.value = window.SITE_SETTINGS.show_server_ip ? "1" : "0";
|
||||||
|
|
||||||
|
// Apply security dependency
|
||||||
|
updateSecurityDependency();
|
||||||
|
|
||||||
|
// Load custom metrics
|
||||||
|
loadCustomMetricsUI(window.SITE_SETTINGS.custom_metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSiteSettings();
|
loadSiteSettings();
|
||||||
|
|
||||||
|
// Bind save button for security tab
|
||||||
|
if (dom.btnSaveSecuritySettings) {
|
||||||
|
dom.btnSaveSecuritySettings.addEventListener('click', saveSiteSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security dependency listener
|
||||||
|
if (dom.requireLoginForServerDetailsInput) {
|
||||||
|
dom.requireLoginForServerDetailsInput.addEventListener('change', updateSecurityDependency);
|
||||||
|
}
|
||||||
|
|
||||||
// Track intervals for resource management
|
// Track intervals for resource management
|
||||||
initWebSocket();
|
initWebSocket();
|
||||||
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
|
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
|
||||||
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
|
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
|
||||||
|
console.log('[Init] Success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Init Failure]', err);
|
||||||
|
if (window.onerror) window.onerror('Initialization failed: ' + err.message, '', 0, 0, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Real-time WebSocket ----
|
// ---- Real-time WebSocket ----
|
||||||
@@ -597,6 +733,14 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const m = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
return h + ':' + m + ':' + s;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Theme Switching ----
|
// ---- Theme Switching ----
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const theme = dom.themeToggle.checked ? 'light' : 'dark';
|
const theme = dom.themeToggle.checked ? 'light' : 'dark';
|
||||||
@@ -666,7 +810,9 @@
|
|||||||
function updateUserUI(username) {
|
function updateUserUI(username) {
|
||||||
if (username) {
|
if (username) {
|
||||||
user = username;
|
user = username;
|
||||||
dom.btnSettings.style.display = 'flex';
|
if (dom.btnSettings) dom.btnSettings.style.display = 'flex';
|
||||||
|
if (dom.btnGlobalRefresh) dom.btnGlobalRefresh.style.display = 'flex';
|
||||||
|
|
||||||
dom.userSection.innerHTML = `
|
dom.userSection.innerHTML = `
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">${escapeHtml(username)}</span>
|
<span class="username">${escapeHtml(username)}</span>
|
||||||
@@ -676,7 +822,9 @@
|
|||||||
document.getElementById('btnLogout').addEventListener('click', handleLogout);
|
document.getElementById('btnLogout').addEventListener('click', handleLogout);
|
||||||
} else {
|
} else {
|
||||||
user = null;
|
user = null;
|
||||||
dom.btnSettings.style.display = 'none';
|
if (dom.btnSettings) dom.btnSettings.style.display = 'none';
|
||||||
|
if (dom.btnGlobalRefresh) dom.btnGlobalRefresh.style.display = 'none';
|
||||||
|
|
||||||
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
|
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
|
||||||
document.getElementById('btnLogin').addEventListener('click', openLoginModal);
|
document.getElementById('btnLogin').addEventListener('click', openLoginModal);
|
||||||
}
|
}
|
||||||
@@ -757,6 +905,16 @@
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
updateUserUI(data.username);
|
updateUserUI(data.username);
|
||||||
closeLoginModal();
|
closeLoginModal();
|
||||||
|
|
||||||
|
// Refresh data sources list for the filter dropdown
|
||||||
|
loadSourceCount();
|
||||||
|
|
||||||
|
// Refresh site settings (logo, filings, theme, etc.)
|
||||||
|
loadSiteSettings();
|
||||||
|
|
||||||
|
// Refresh dashboard data
|
||||||
|
fetchMetrics(true);
|
||||||
|
fetchNetworkHistory(true);
|
||||||
} else {
|
} else {
|
||||||
dom.loginError.textContent = data.error || '登录失败';
|
dom.loginError.textContent = data.error || '登录失败';
|
||||||
dom.loginError.style.display = 'block';
|
dom.loginError.style.display = 'block';
|
||||||
@@ -1025,8 +1183,8 @@
|
|||||||
'shanghai': [121.4737, 31.2304],
|
'shanghai': [121.4737, 31.2304],
|
||||||
'hong kong': [114.1694, 22.3193],
|
'hong kong': [114.1694, 22.3193],
|
||||||
'hk': [114.1694, 22.3193],
|
'hk': [114.1694, 22.3193],
|
||||||
'taiwan': [120.9605, 23.6978],
|
'taiwan': [121.5654, 25.0330],
|
||||||
'tw': [120.9605, 23.6978],
|
'tw': [121.5654, 25.0330],
|
||||||
'united states': [-95.7129, 37.0902],
|
'united states': [-95.7129, 37.0902],
|
||||||
'us': [-95.7129, 37.0902],
|
'us': [-95.7129, 37.0902],
|
||||||
'us seattle': [-122.3321, 47.6062],
|
'us seattle': [-122.3321, 47.6062],
|
||||||
@@ -1039,8 +1197,8 @@
|
|||||||
'new york corp': [-74.0060, 40.7128],
|
'new york corp': [-74.0060, 40.7128],
|
||||||
'san francisco': [-122.4194, 37.7749],
|
'san francisco': [-122.4194, 37.7749],
|
||||||
'los angeles': [-118.2437, 34.0522],
|
'los angeles': [-118.2437, 34.0522],
|
||||||
'japan': [138.2529, 36.2048],
|
'japan': [139.6917, 35.6895],
|
||||||
'jp': [138.2529, 36.2048],
|
'jp': [139.6917, 35.6895],
|
||||||
'tokyo': [139.6917, 35.6895],
|
'tokyo': [139.6917, 35.6895],
|
||||||
'singapore': [103.8198, 1.3521],
|
'singapore': [103.8198, 1.3521],
|
||||||
'sg': [103.8198, 1.3521],
|
'sg': [103.8198, 1.3521],
|
||||||
@@ -1250,7 +1408,8 @@
|
|||||||
</div>` },
|
</div>` },
|
||||||
{ key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(server.diskPercent) },
|
{ key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(server.diskPercent) },
|
||||||
{ key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(server.netRx) },
|
{ key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(server.netRx) },
|
||||||
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) }
|
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) },
|
||||||
|
{ key: 'conntrackUsedPct', label: 'Conntrack 占用比例', value: formatPercent(server.conntrackPercent) }
|
||||||
];
|
];
|
||||||
|
|
||||||
metrics.forEach(m => {
|
metrics.forEach(m => {
|
||||||
@@ -1340,6 +1499,14 @@
|
|||||||
valA = a.netTx ?? 0;
|
valA = a.netTx ?? 0;
|
||||||
valB = b.netTx ?? 0;
|
valB = b.netTx ?? 0;
|
||||||
break;
|
break;
|
||||||
|
case 'traffic24h':
|
||||||
|
valA = (a.traffic24hRx ?? 0) + (a.traffic24hTx ?? 0);
|
||||||
|
valB = (b.traffic24hRx ?? 0) + (b.traffic24hTx ?? 0);
|
||||||
|
break;
|
||||||
|
case 'conntrack':
|
||||||
|
valA = a.conntrackPercent ?? 0;
|
||||||
|
valB = b.conntrackPercent ?? 0;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
valA = (a.job || '').toLowerCase();
|
valA = (a.job || '').toLowerCase();
|
||||||
valB = (b.job || '').toLowerCase();
|
valB = (b.job || '').toLowerCase();
|
||||||
@@ -1465,7 +1632,7 @@
|
|||||||
if (!servers || servers.length === 0) {
|
if (!servers || servers.length === 0) {
|
||||||
dom.serverTableBody.innerHTML = `
|
dom.serverTableBody.innerHTML = `
|
||||||
<tr class="empty-row">
|
<tr class="empty-row">
|
||||||
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
|
<td colspan="10">暂无数据 - 请先配置 Prometheus 数据源</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -1510,6 +1677,18 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>${formatBandwidth(server.netRx)}</td>
|
<td>${formatBandwidth(server.netRx)}</td>
|
||||||
<td>${formatBandwidth(server.netTx)}</td>
|
<td>${formatBandwidth(server.netTx)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="usage-bar">
|
||||||
|
<div class="usage-bar-track">
|
||||||
|
<div class="usage-bar-fill usage-bar-fill-cpu" style="width: ${Math.min(server.conntrackPercent || 0, 100)}%; background: var(--accent-indigo);"></div>
|
||||||
|
</div>
|
||||||
|
<span>${formatPercent(server.conntrackPercent || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 0.85rem; font-weight: 500; color: var(--text-primary);">${formatBytes((server.traffic24hRx || 0) + (server.traffic24hTx || 0))}</div>
|
||||||
|
<div style="font-size: 0.65rem; color: var(--text-muted); margin-top: 2px;">↓${formatBytes(server.traffic24hRx || 0)} / ↑${formatBytes(server.traffic24hTx || 0)}</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -1581,6 +1760,28 @@
|
|||||||
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0);
|
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IP Addresses
|
||||||
|
const infoGrid = document.getElementById('detailInfoGrid');
|
||||||
|
if (infoGrid) {
|
||||||
|
// Remove any previously added IP items
|
||||||
|
infoGrid.querySelectorAll('.info-item-ip').forEach(el => el.remove());
|
||||||
|
|
||||||
|
if (window.SITE_SETTINGS && window.SITE_SETTINGS.show_server_ip) {
|
||||||
|
if (data.ipv4 && data.ipv4.length > 0) {
|
||||||
|
const ipv4Item = document.createElement('div');
|
||||||
|
ipv4Item.className = 'info-item info-item-ip';
|
||||||
|
ipv4Item.innerHTML = `<span class="info-label">IPv4 地址</span><span class="info-value">${escapeHtml(data.ipv4.join(', '))}</span>`;
|
||||||
|
infoGrid.appendChild(ipv4Item);
|
||||||
|
}
|
||||||
|
if (data.ipv6 && data.ipv6.length > 0) {
|
||||||
|
const ipv6Item = document.createElement('div');
|
||||||
|
ipv6Item.className = 'info-item info-item-ip';
|
||||||
|
ipv6Item.innerHTML = `<span class="info-label">IPv6 地址</span><span class="info-value" style="font-size: 0.65rem; word-break: break-all;">${escapeHtml(data.ipv6.join(', '))}</span>`;
|
||||||
|
infoGrid.appendChild(ipv6Item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Define metrics to show
|
// Define metrics to show
|
||||||
const cpuValueHtml = `
|
const cpuValueHtml = `
|
||||||
<div style="display: flex; align-items: baseline; gap: 8px;">
|
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||||||
@@ -1602,6 +1803,11 @@
|
|||||||
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) },
|
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) },
|
||||||
{ key: 'sockstatTcp', label: 'TCP 链接数 (Sockstat)', value: data.sockstatTcp.toFixed(0) },
|
{ key: 'sockstatTcp', label: 'TCP 链接数 (Sockstat)', value: data.sockstatTcp.toFixed(0) },
|
||||||
{ key: 'sockstatTcpMem', label: 'TCP 内存占用', value: formatBytes(data.sockstatTcpMem) },
|
{ key: 'sockstatTcpMem', label: 'TCP 内存占用', value: formatBytes(data.sockstatTcpMem) },
|
||||||
|
{ key: 'conntrackUsedPct', label: 'Conntrack 占用比例', value: `
|
||||||
|
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||||||
|
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(data.conntrackUsedPct)}</span>
|
||||||
|
<span style="font-size: 0.7rem; color: var(--text-secondary); font-weight: normal;">(${data.conntrackEntries.toFixed(0)} / ${data.conntrackLimit.toFixed(0)})</span>
|
||||||
|
</div>` },
|
||||||
{ key: 'networkTrend', label: '网络流量趋势 (24h)', value: '' }
|
{ key: 'networkTrend', label: '网络流量趋势 (24h)', value: '' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1665,7 +1871,25 @@
|
|||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric
|
// Render Custom Data
|
||||||
|
const customDataContainer = dom.customDataContainer;
|
||||||
|
if (customDataContainer) {
|
||||||
|
customDataContainer.innerHTML = '';
|
||||||
|
if (data.custom_data && data.custom_data.length > 0) {
|
||||||
|
data.custom_data.forEach(item => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'detail-metric-card';
|
||||||
|
card.style.flex = '1 1 calc(50% - 10px)';
|
||||||
|
card.innerHTML = `
|
||||||
|
<span class="detail-metric-label">${escapeHtml(item.name)}</span>
|
||||||
|
<span class="detail-metric-value">${escapeHtml(item.value || '-')}</span>
|
||||||
|
`;
|
||||||
|
customDataContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partitions
|
||||||
if (data.partitions && data.partitions.length > 0) {
|
if (data.partitions && data.partitions.length > 0) {
|
||||||
dom.detailPartitionsContainer.style.display = 'block';
|
dom.detailPartitionsContainer.style.display = 'block';
|
||||||
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
|
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
|
||||||
@@ -1676,6 +1900,19 @@
|
|||||||
diskMetricItem.after(dom.detailPartitionsContainer);
|
diskMetricItem.after(dom.detailPartitionsContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dom.detailPartitionsList.innerHTML = data.partitions.map(p => `
|
||||||
|
<div class="partition-row">
|
||||||
|
<div class="partition-info">
|
||||||
|
<span class="partition-name">${escapeHtml(p.mountpoint || p.device)}</span>
|
||||||
|
<span class="partition-usage-text">${escapeHtml(formatBytes(p.used))} / ${escapeHtml(formatBytes(p.total))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="partition-bar-bg">
|
||||||
|
<div class="partition-bar-fill ${getUsageClass(p.percent)}" style="width: ${p.percent}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="partition-percent">${p.percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
dom.partitionHeader.onclick = (e) => {
|
dom.partitionHeader.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dom.detailPartitionsContainer.classList.toggle('active');
|
dom.detailPartitionsContainer.classList.toggle('active');
|
||||||
@@ -1733,6 +1970,7 @@
|
|||||||
if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
|
if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
|
||||||
if (metricKey.startsWith('net')) unit = 'B/s';
|
if (metricKey.startsWith('net')) unit = 'B/s';
|
||||||
if (metricKey === 'sockstatTcpMem') unit = 'B';
|
if (metricKey === 'sockstatTcpMem') unit = 'B';
|
||||||
|
if (metricKey === 'conntrackUsedPct') unit = '%';
|
||||||
|
|
||||||
if (metricKey === 'networkTrend') {
|
if (metricKey === 'networkTrend') {
|
||||||
chart = new AreaChart(canvas);
|
chart = new AreaChart(canvas);
|
||||||
@@ -1747,6 +1985,7 @@
|
|||||||
if (metricKey === 'memUsedPct') chart.totalValue = currentServerDetail.memTotal;
|
if (metricKey === 'memUsedPct') chart.totalValue = currentServerDetail.memTotal;
|
||||||
if (metricKey === 'swapUsedPct') chart.totalValue = currentServerDetail.swapTotal;
|
if (metricKey === 'swapUsedPct') chart.totalValue = currentServerDetail.swapTotal;
|
||||||
if (metricKey === 'rootFsUsedPct') chart.totalValue = currentServerDetail.rootFsTotal;
|
if (metricKey === 'rootFsUsedPct') chart.totalValue = currentServerDetail.rootFsTotal;
|
||||||
|
if (metricKey === 'conntrackUsedPct') chart.totalValue = data.conntrackLimit;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { instance, job, source } = currentServerDetail;
|
const { instance, job, source } = currentServerDetail;
|
||||||
@@ -1907,7 +2146,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
// 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';
|
||||||
@@ -1928,12 +2167,24 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
siteThemeQuery.addEventListener('change', siteThemeHandler);
|
siteThemeQuery.addEventListener('change', siteThemeHandler);
|
||||||
|
|
||||||
|
// Update IP visibility input
|
||||||
|
if (dom.showServerIpInput) dom.showServerIpInput.value = settings.show_server_ip ? "1" : "0";
|
||||||
|
if (dom.ipMetricNameInput) dom.ipMetricNameInput.value = settings.ip_metric_name || '';
|
||||||
|
if (dom.ipLabelNameInput) dom.ipLabelNameInput.value = settings.ip_label_name || '';
|
||||||
|
|
||||||
|
// Load Custom Metrics
|
||||||
|
loadCustomMetricsUI(settings.custom_metrics);
|
||||||
|
|
||||||
|
// Sync security tab dependency
|
||||||
|
updateSecurityDependency();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading site settings:', err);
|
console.error('Error loading site settings:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySiteSettings(settings) {
|
function applySiteSettings(settings) {
|
||||||
|
if (!settings) return;
|
||||||
if (settings.page_name) {
|
if (settings.page_name) {
|
||||||
document.title = settings.page_name;
|
document.title = settings.page_name;
|
||||||
}
|
}
|
||||||
@@ -1987,40 +2238,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filing info
|
// Filing info
|
||||||
let hasFilings = false;
|
let hasPs = !!settings.ps_filing;
|
||||||
|
let hasIcp = !!settings.icp_filing;
|
||||||
|
|
||||||
if (dom.psFilingDisplay) {
|
if (dom.psFilingDisplay) {
|
||||||
if (settings.ps_filing) {
|
if (hasPs) {
|
||||||
if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing;
|
if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing;
|
||||||
dom.psFilingDisplay.style.display = 'inline-block';
|
dom.psFilingDisplay.style.display = 'inline-block';
|
||||||
hasFilings = true;
|
|
||||||
} else {
|
} else {
|
||||||
dom.psFilingDisplay.style.display = 'none';
|
dom.psFilingDisplay.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dom.icpFilingDisplay) {
|
if (dom.icpFilingDisplay) {
|
||||||
if (settings.icp_filing) {
|
if (hasIcp) {
|
||||||
dom.icpFilingDisplay.textContent = settings.icp_filing;
|
dom.icpFilingDisplay.textContent = settings.icp_filing;
|
||||||
dom.icpFilingDisplay.style.display = 'inline-block';
|
dom.icpFilingDisplay.style.display = 'inline-block';
|
||||||
hasFilings = true;
|
|
||||||
} else {
|
} else {
|
||||||
dom.icpFilingDisplay.style.display = 'none';
|
dom.icpFilingDisplay.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle separator
|
||||||
|
const filingSep = document.querySelector('.filing-sep');
|
||||||
|
if (filingSep) {
|
||||||
|
// Small adjustment: the CSS will handle the PC-only display,
|
||||||
|
// here we just handle the logical "both exist" requirement.
|
||||||
|
filingSep.style.display = (hasPs && hasIcp) ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const footerContent = document.querySelector('.footer-content');
|
const footerContent = document.querySelector('.footer-content');
|
||||||
if (footerContent) {
|
if (footerContent) {
|
||||||
footerContent.classList.toggle('only-copyright', !hasFilings);
|
footerContent.classList.toggle('only-copyright', !(hasPs || hasIcp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSiteSettings() {
|
async function saveSiteSettings(e) {
|
||||||
|
let messageTarget = dom.siteSettingsMessage;
|
||||||
|
if (e && e.target) {
|
||||||
|
if (e.target.id === 'btnSaveSecuritySettings') messageTarget = dom.securitySettingsMessage;
|
||||||
|
else if (e.target.id === 'btnSaveCustomMetrics') messageTarget = dom.customMetricsMessage;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
showSiteMessage('请先登录后操作', 'error');
|
showSiteMessage('请先登录后操作', 'error', messageTarget);
|
||||||
openLoginModal();
|
openLoginModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveButtons = [dom.btnSaveSiteSettings, dom.btnSaveSecuritySettings, dom.btnSaveCustomMetrics].filter(b => b);
|
||||||
|
saveButtons.forEach(btn => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.originalText = btn.textContent;
|
||||||
|
btn.textContent = '保存中...';
|
||||||
|
});
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
page_name: dom.pageNameInput.value.trim(),
|
page_name: dom.pageNameInput.value.trim(),
|
||||||
title: dom.siteTitleInput ? dom.siteTitleInput.value.trim() : dom.pageNameInput.value.trim(),
|
title: dom.siteTitleInput ? dom.siteTitleInput.value.trim() : dom.pageNameInput.value.trim(),
|
||||||
@@ -2033,12 +2305,13 @@
|
|||||||
show_95_bandwidth: dom.show95BandwidthInput ? (dom.show95BandwidthInput.value === "1") : false,
|
show_95_bandwidth: dom.show95BandwidthInput ? (dom.show95BandwidthInput.value === "1") : false,
|
||||||
p95_type: dom.p95TypeSelect ? dom.p95TypeSelect.value : 'tx',
|
p95_type: dom.p95TypeSelect ? dom.p95TypeSelect.value : 'tx',
|
||||||
ps_filing: dom.psFilingInput ? dom.psFilingInput.value.trim() : '',
|
ps_filing: dom.psFilingInput ? dom.psFilingInput.value.trim() : '',
|
||||||
icp_filing: dom.icpFilingInput ? dom.icpFilingInput.value.trim() : ''
|
icp_filing: dom.icpFilingInput ? dom.icpFilingInput.value.trim() : '',
|
||||||
|
show_server_ip: dom.showServerIpInput ? (dom.showServerIpInput.value === "1") : false,
|
||||||
|
ip_metric_name: dom.ipMetricNameInput ? dom.ipMetricNameInput.value.trim() : null,
|
||||||
|
ip_label_name: dom.ipLabelNameInput ? dom.ipLabelNameInput.value.trim() : 'address',
|
||||||
|
custom_metrics: getCustomMetricsFromUI()
|
||||||
};
|
};
|
||||||
|
|
||||||
dom.btnSaveSiteSettings.disabled = true;
|
|
||||||
dom.btnSaveSiteSettings.textContent = '保存中...';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings', {
|
const response = await fetch('/api/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2047,23 +2320,52 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showSiteMessage('设置保存成功', 'success');
|
const data = await response.json();
|
||||||
// Update global object and UI immediately
|
window.SITE_SETTINGS = data.settings;
|
||||||
window.SITE_SETTINGS = { ...window.SITE_SETTINGS, ...settings };
|
applySiteSettings(window.SITE_SETTINGS);
|
||||||
|
showSiteMessage('设置保存成功', 'success', messageTarget);
|
||||||
|
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const themeToApply = savedTheme || settings.default_theme || 'dark';
|
const themeToApply = savedTheme || settings.default_theme || 'dark';
|
||||||
applyTheme(themeToApply);
|
applyTheme(themeToApply);
|
||||||
|
|
||||||
|
// Refresh overview and historical charts to reflect new source selections
|
||||||
|
fetchNetworkHistory(true);
|
||||||
|
fetch('/api/metrics/overview?force=true')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => updateDashboard(data))
|
||||||
|
.catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
const err = await response.json();
|
const err = await response.json();
|
||||||
showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error');
|
showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error', messageTarget);
|
||||||
if (response.status === 401) openLoginModal();
|
if (response.status === 401) openLoginModal();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSiteMessage(`保存失败: ${err.message}`, 'error');
|
showSiteMessage(`请求失败: ${err.message}`, 'error', messageTarget);
|
||||||
console.error('Save settings error:', err);
|
console.error('Save settings error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
dom.btnSaveSiteSettings.disabled = false;
|
saveButtons.forEach(btn => {
|
||||||
dom.btnSaveSiteSettings.textContent = '保存设置';
|
btn.disabled = false;
|
||||||
|
btn.textContent = btn.originalText || '保存设置';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSecurityDependency() {
|
||||||
|
if (!dom.requireLoginForServerDetailsInput || !dom.showServerIpInput) return;
|
||||||
|
|
||||||
|
const requireLogin = dom.requireLoginForServerDetailsInput.value === "1";
|
||||||
|
if (!requireLogin) {
|
||||||
|
// If public access is allowed, force hide IP and disable the toggle
|
||||||
|
dom.showServerIpInput.value = "0";
|
||||||
|
dom.showServerIpInput.disabled = true;
|
||||||
|
dom.showServerIpInput.style.opacity = "0.6";
|
||||||
|
dom.showServerIpInput.parentElement.style.opacity = "0.7";
|
||||||
|
} else {
|
||||||
|
// Re-enable when login is required
|
||||||
|
dom.showServerIpInput.disabled = false;
|
||||||
|
dom.showServerIpInput.style.opacity = "1";
|
||||||
|
dom.showServerIpInput.parentElement.style.opacity = "1";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2076,6 +2378,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const routes = await response.json();
|
const routes = await response.json();
|
||||||
|
allStoredLatencyRoutes = routes;
|
||||||
renderLatencyRoutes(routes);
|
renderLatencyRoutes(routes);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading latency routes:', err);
|
console.error('Error loading latency routes:', err);
|
||||||
@@ -2089,7 +2392,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
dom.latencyRoutesList.innerHTML = routes.map(route => `
|
dom.latencyRoutesList.innerHTML = routes.map(route => `
|
||||||
<div class="latency-route-item" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;">
|
<div class="latency-route-item" data-id="${route.id}" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;">
|
||||||
<div class="route-info" style="display: flex; flex-direction: column; gap: 4px;">
|
<div class="route-info" style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
<div style="font-weight: 600; font-size: 0.88rem; color: var(--text-primary);">
|
<div style="font-weight: 600; font-size: 0.88rem; color: var(--text-primary);">
|
||||||
${escapeHtml(route.latency_source)} ↔ ${escapeHtml(route.latency_dest)}
|
${escapeHtml(route.latency_source)} ↔ ${escapeHtml(route.latency_dest)}
|
||||||
@@ -2100,25 +2403,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="route-actions" style="display: flex; gap: 8px;">
|
<div class="route-actions" style="display: flex; gap: 8px;">
|
||||||
<button class="btn btn-test" onclick="editRoute(${route.id}, ${route.source_id}, '${escapeHtml(route.latency_source)}', '${escapeHtml(route.latency_dest)}', '${escapeHtml(route.latency_target)}')" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button>
|
<button class="btn btn-test btn-edit-route" data-id="${route.id}" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button>
|
||||||
<button class="btn btn-delete" onclick="deleteLatencyRoute(${route.id})" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
|
<button class="btn btn-delete btn-delete-route" data-id="${route.id}" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.editRoute = function (id, source_id, source, dest, target) {
|
window.editRoute = function (id, source_id, source, dest, target) {
|
||||||
|
let route = null;
|
||||||
|
if (source_id === undefined && allStoredLatencyRoutes) {
|
||||||
|
route = allStoredLatencyRoutes.find(r => r.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
editingRouteId = id;
|
editingRouteId = id;
|
||||||
dom.routeSourceSelect.value = source_id;
|
dom.routeSourceSelect.value = route ? route.source_id : (source_id || '');
|
||||||
dom.routeSourceInput.value = source;
|
dom.routeSourceInput.value = route ? route.latency_source : (source || '');
|
||||||
dom.routeDestInput.value = dest;
|
dom.routeDestInput.value = route ? route.latency_dest : (dest || '');
|
||||||
dom.routeTargetInput.value = target;
|
dom.routeTargetInput.value = route ? route.latency_target : (target || '');
|
||||||
|
|
||||||
dom.btnAddRoute.textContent = '保存修改';
|
dom.btnAddRoute.textContent = '保存修改';
|
||||||
dom.btnCancelEditRoute.style.display = 'block';
|
dom.btnCancelEditRoute.style.display = 'block';
|
||||||
|
|
||||||
// Select the tab just in case (though it's already there)
|
// Select the tab 'latency' (not 'routes')
|
||||||
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
|
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'latency');
|
||||||
if (tab) tab.click();
|
if (tab) tab.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2197,14 +2505,23 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function showSiteMessage(text, type) {
|
function showSiteMessage(text, type, target = null) {
|
||||||
dom.siteSettingsMessage.textContent = text;
|
const el = target || dom.siteSettingsMessage;
|
||||||
dom.siteSettingsMessage.className = `form-message ${type}`;
|
if (!el) return;
|
||||||
setTimeout(hideSiteMessage, 5000);
|
el.textContent = text;
|
||||||
|
el.className = `form-message ${type}`;
|
||||||
|
|
||||||
|
// Clear existing timeout if any (simplified)
|
||||||
|
if (el._msgTimeout) clearTimeout(el._msgTimeout);
|
||||||
|
el._msgTimeout = setTimeout(() => {
|
||||||
|
el.className = 'form-message';
|
||||||
|
el._msgTimeout = null;
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSiteMessage() {
|
function hideSiteMessage(target = null) {
|
||||||
dom.siteSettingsMessage.className = 'form-message';
|
const el = target || dom.siteSettingsMessage;
|
||||||
|
if (el) el.className = 'form-message';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChangePassword() {
|
async function saveChangePassword() {
|
||||||
@@ -2306,13 +2623,20 @@
|
|||||||
|
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
try {
|
try {
|
||||||
|
if (dom.sourceItems) {
|
||||||
|
dom.sourceItems.innerHTML = '<div class="source-loading"><div class="dot dot-pulse"></div><span>正在加载数据源...</span></div>';
|
||||||
|
}
|
||||||
const response = await fetch('/api/sources');
|
const response = await fetch('/api/sources');
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
if (dom.sourceItems) {
|
||||||
|
dom.sourceItems.innerHTML = '<div class="source-empty">请登录后管理数据源</div>';
|
||||||
|
}
|
||||||
promptLogin('登录后可查看和管理数据源');
|
promptLogin('登录后可查看和管理数据源');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sources = await response.json();
|
const sources = await response.json();
|
||||||
const sourcesArray = Array.isArray(sources) ? sources : [];
|
const sourcesArray = Array.isArray(sources) ? sources : [];
|
||||||
|
allStoredSources = sourcesArray;
|
||||||
const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
|
const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
|
||||||
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`;
|
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`;
|
||||||
updateSourceFilterOptions(sourcesArray);
|
updateSourceFilterOptions(sourcesArray);
|
||||||
@@ -2336,21 +2660,24 @@
|
|||||||
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
|
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
|
||||||
${source.status === 'online' ? '在线' : '离线'}
|
${source.status === 'online' ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
|
${source.type === 'blackbox' ? '<span class="source-type-badge type-other">Blackbox</span>' : `
|
||||||
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
|
${source.is_overview_source ? '<span class="source-type-badge type-server" style="background: var(--accent-indigo);">总览</span>' : ''}
|
||||||
</span>
|
${source.is_detail_source ? '<span class="source-type-badge type-server" style="background: var(--accent-emerald);">详情</span>' : ''}
|
||||||
|
${!source.is_overview_source && !source.is_detail_source ? '<span class="source-type-badge type-other">独立数据源</span>' : ''}
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="source-item-url">${escapeHtml(source.url)}</div>
|
<div class="source-item-url">${escapeHtml(source.url)}</div>
|
||||||
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
|
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="source-item-actions">
|
<div class="source-item-actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editSource(${JSON.stringify(source).replace(/"/g, '"')})">编辑</button>
|
<button class="btn btn-secondary btn-sm btn-edit-source" data-id="${source.id}">编辑</button>
|
||||||
<button class="btn btn-delete btn-sm" onclick="deleteSource(${source.id})">删除</button>
|
<button class="btn btn-delete btn-sm btn-delete-source" data-id="${source.id}">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---- Test Connection ----
|
// ---- Test Connection ----
|
||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
const url = dom.sourceUrl.value.trim();
|
const url = dom.sourceUrl.value.trim();
|
||||||
@@ -2392,19 +2719,26 @@
|
|||||||
// ---- Add Source ----
|
// ---- Add Source ----
|
||||||
let editingSourceId = null;
|
let editingSourceId = null;
|
||||||
|
|
||||||
window.editSource = function(source) {
|
window.editSource = function(sourceOrId) {
|
||||||
|
let source = sourceOrId;
|
||||||
|
if (typeof sourceOrId !== 'object' && allStoredSources) {
|
||||||
|
source = allStoredSources.find(s => s.id === sourceOrId);
|
||||||
|
}
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
editingSourceId = source.id;
|
editingSourceId = source.id;
|
||||||
dom.sourceName.value = source.name || '';
|
dom.sourceName.value = source.name || '';
|
||||||
dom.sourceUrl.value = source.url || '';
|
dom.sourceUrl.value = source.url || '';
|
||||||
dom.sourceType.value = source.type || 'prometheus';
|
dom.sourceType.value = source.type || 'prometheus';
|
||||||
dom.sourceDesc.value = source.description || '';
|
dom.sourceDesc.value = source.description || '';
|
||||||
dom.isServerSource.checked = !!source.is_server_source;
|
if (dom.isOverviewSource) dom.isOverviewSource.checked = !!source.is_overview_source;
|
||||||
|
if (dom.isDetailSource) dom.isDetailSource.checked = !!source.is_detail_source;
|
||||||
|
|
||||||
// Toggle Blackbox UI
|
// Toggle Blackbox UI
|
||||||
if (source.type === 'blackbox') {
|
if (source.type === 'blackbox') {
|
||||||
dom.serverSourceOption.style.display = 'none';
|
dom.serverSourceOption.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
dom.serverSourceOption.style.display = 'flex';
|
dom.serverSourceOption.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
dom.btnAdd.textContent = '保存修改';
|
dom.btnAdd.textContent = '保存修改';
|
||||||
@@ -2430,8 +2764,9 @@
|
|||||||
dom.sourceUrl.value = '';
|
dom.sourceUrl.value = '';
|
||||||
dom.sourceType.value = 'prometheus';
|
dom.sourceType.value = 'prometheus';
|
||||||
dom.sourceDesc.value = '';
|
dom.sourceDesc.value = '';
|
||||||
dom.isServerSource.checked = true;
|
if (dom.isOverviewSource) dom.isOverviewSource.checked = true;
|
||||||
dom.serverSourceOption.style.display = 'flex';
|
if (dom.isDetailSource) dom.isDetailSource.checked = true;
|
||||||
|
dom.serverSourceOption.style.display = '';
|
||||||
dom.btnAdd.textContent = '添加';
|
dom.btnAdd.textContent = '添加';
|
||||||
|
|
||||||
const cancelBtn = document.getElementById('btnCancelEditSource');
|
const cancelBtn = document.getElementById('btnCancelEditSource');
|
||||||
@@ -2451,7 +2786,9 @@
|
|||||||
const type = dom.sourceType.value;
|
const type = dom.sourceType.value;
|
||||||
const description = dom.sourceDesc.value.trim();
|
const description = dom.sourceDesc.value.trim();
|
||||||
// Default to false for blackbox, otherwise use checkbox
|
// Default to false for blackbox, otherwise use checkbox
|
||||||
const is_server_source = type === 'blackbox' ? false : dom.isServerSource.checked;
|
const is_overview_source = type === 'blackbox' ? false : dom.isOverviewSource.checked;
|
||||||
|
const is_detail_source = type === 'blackbox' ? false : dom.isDetailSource.checked;
|
||||||
|
const is_server_source = is_overview_source || is_detail_source;
|
||||||
|
|
||||||
if (!name || !url) {
|
if (!name || !url) {
|
||||||
showMessage('请填写名称和URL', 'error');
|
showMessage('请填写名称和URL', 'error');
|
||||||
@@ -2469,7 +2806,7 @@
|
|||||||
const response = await fetch(urlPath, {
|
const response = await fetch(urlPath, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, url, description, is_server_source, type })
|
body: JSON.stringify({ name, url, description, is_server_source, is_overview_source, is_detail_source, type })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -23,9 +23,86 @@ class AreaChart {
|
|||||||
// Use debounced resize for performance and safety
|
// Use debounced resize for performance and safety
|
||||||
this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this);
|
this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this);
|
||||||
window.addEventListener('resize', this._resize);
|
window.addEventListener('resize', this._resize);
|
||||||
|
|
||||||
|
// Drag zoom support
|
||||||
|
this.isDraggingP95 = false;
|
||||||
|
this.customMaxVal = null;
|
||||||
|
|
||||||
|
this.onPointerDown = this.onPointerDown.bind(this);
|
||||||
|
this.onPointerMove = this.onPointerMove.bind(this);
|
||||||
|
this.onPointerUp = this.onPointerUp.bind(this);
|
||||||
|
|
||||||
|
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||||
|
window.addEventListener('pointermove', this.onPointerMove);
|
||||||
|
window.addEventListener('pointerup', this.onPointerUp);
|
||||||
|
|
||||||
this.resize();
|
this.resize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPointerDown(e) {
|
||||||
|
if (!this.showP95 || !this.p95) return;
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const scaleY = this.height / rect.height;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
const p = this.padding;
|
||||||
|
const chartH = this.height - p.top - p.bottom;
|
||||||
|
|
||||||
|
// Calculate current P95 Y position
|
||||||
|
const k = 1024;
|
||||||
|
const currentMaxVal = (this.customMaxVal !== null ? this.customMaxVal : (this.currentMaxVal || 1024));
|
||||||
|
let unitIdx = Math.floor(Math.log(Math.max(1, currentMaxVal)) / Math.log(k));
|
||||||
|
unitIdx = Math.max(0, Math.min(unitIdx, 4));
|
||||||
|
const unitFactor = Math.pow(k, unitIdx);
|
||||||
|
const rawValInUnit = (currentMaxVal * 1.15) / unitFactor;
|
||||||
|
let niceMaxInUnit;
|
||||||
|
if (rawValInUnit <= 1) niceMaxInUnit = 1;
|
||||||
|
else if (rawValInUnit <= 2) niceMaxInUnit = 2;
|
||||||
|
else if (rawValInUnit <= 5) niceMaxInUnit = 5;
|
||||||
|
else if (rawValInUnit <= 10) niceMaxInUnit = 10;
|
||||||
|
else if (rawValInUnit <= 20) niceMaxInUnit = 20;
|
||||||
|
else if (rawValInUnit <= 50) niceMaxInUnit = 50;
|
||||||
|
else if (rawValInUnit <= 100) niceMaxInUnit = 100;
|
||||||
|
else if (rawValInUnit <= 200) niceMaxInUnit = 200;
|
||||||
|
else if (rawValInUnit <= 500) niceMaxInUnit = 500;
|
||||||
|
else if (rawValInUnit <= 1000) niceMaxInUnit = 1000;
|
||||||
|
else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100;
|
||||||
|
|
||||||
|
const displayMaxVal = this.customMaxVal !== null ? this.customMaxVal : (niceMaxInUnit * unitFactor);
|
||||||
|
const p95Y = p.top + chartH - (this.p95 / (displayMaxVal || 1)) * chartH;
|
||||||
|
|
||||||
|
if (Math.abs(y - p95Y) < 25) {
|
||||||
|
this.isDraggingP95 = true;
|
||||||
|
this.canvas.style.cursor = 'ns-resize';
|
||||||
|
this.canvas.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(e) {
|
||||||
|
if (!this.isDraggingP95) return;
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const scaleY = this.height / rect.height;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
const p = this.padding;
|
||||||
|
const chartH = this.height - p.top - p.bottom;
|
||||||
|
|
||||||
|
const dy = p.top + chartH - y;
|
||||||
|
if (dy > 10) {
|
||||||
|
this.customMaxVal = (this.p95 * chartH) / dy;
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(e) {
|
||||||
|
if (this.isDraggingP95) {
|
||||||
|
this.isDraggingP95 = false;
|
||||||
|
this.canvas.style.cursor = '';
|
||||||
|
this.canvas.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
const rect = this.canvas.parentElement.getBoundingClientRect();
|
const rect = this.canvas.parentElement.getBoundingClientRect();
|
||||||
this.width = rect.width;
|
this.width = rect.width;
|
||||||
@@ -164,13 +241,10 @@ class AreaChart {
|
|||||||
let unitIdx = Math.floor(Math.log(Math.max(1, maxDataVal)) / Math.log(k));
|
let unitIdx = Math.floor(Math.log(Math.max(1, maxDataVal)) / Math.log(k));
|
||||||
unitIdx = Math.max(0, Math.min(unitIdx, sizes.length - 1));
|
unitIdx = Math.max(0, Math.min(unitIdx, sizes.length - 1));
|
||||||
const unitFactor = Math.pow(k, unitIdx);
|
const unitFactor = Math.pow(k, unitIdx);
|
||||||
const unitLabel = sizes[unitIdx];
|
|
||||||
|
|
||||||
// Get value in current units and find a "nice" round max
|
// Get value in current units and find a "nice" round max
|
||||||
// Use 1.15 cushion
|
|
||||||
const rawValInUnit = (maxDataVal * 1.15) / unitFactor;
|
const rawValInUnit = (maxDataVal * 1.15) / unitFactor;
|
||||||
let niceMaxInUnit;
|
let niceMaxInUnit;
|
||||||
|
|
||||||
if (rawValInUnit <= 1) niceMaxInUnit = 1;
|
if (rawValInUnit <= 1) niceMaxInUnit = 1;
|
||||||
else if (rawValInUnit <= 2) niceMaxInUnit = 2;
|
else if (rawValInUnit <= 2) niceMaxInUnit = 2;
|
||||||
else if (rawValInUnit <= 5) niceMaxInUnit = 5;
|
else if (rawValInUnit <= 5) niceMaxInUnit = 5;
|
||||||
@@ -183,7 +257,16 @@ class AreaChart {
|
|||||||
else if (rawValInUnit <= 1000) niceMaxInUnit = 1000;
|
else if (rawValInUnit <= 1000) niceMaxInUnit = 1000;
|
||||||
else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100;
|
else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100;
|
||||||
|
|
||||||
const maxVal = niceMaxInUnit * unitFactor;
|
let maxVal = niceMaxInUnit * unitFactor;
|
||||||
|
if (this.customMaxVal !== null) {
|
||||||
|
maxVal = this.customMaxVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate units based on final maxVal (could be zoomed)
|
||||||
|
let finalUnitIdx = Math.floor(Math.log(Math.max(1, maxVal)) / Math.log(k));
|
||||||
|
finalUnitIdx = Math.max(0, Math.min(finalUnitIdx, sizes.length - 1));
|
||||||
|
const finalFactor = Math.pow(k, finalUnitIdx);
|
||||||
|
const finalUnitLabel = sizes[finalUnitIdx];
|
||||||
|
|
||||||
const len = timestamps.length;
|
const len = timestamps.length;
|
||||||
const xStep = chartW / (len - 1);
|
const xStep = chartW / (len - 1);
|
||||||
@@ -201,21 +284,21 @@ class AreaChart {
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
const gridLines = 4;
|
const gridLines = 4;
|
||||||
for (let i = 0; i <= gridLines; i++) {
|
for (let i = 0; i <= gridLines; i++) {
|
||||||
const y = p.top + (chartH / gridLines) * i;
|
const y = p.top + (chartH / gridLines) * i;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(p.left, y);
|
ctx.moveTo(p.left, y);
|
||||||
ctx.lineTo(p.left + chartW, y);
|
ctx.lineTo(p.left + chartW, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Y-axis labels - share the same unit for readability
|
// Y-axis labels
|
||||||
const valInUnit = niceMaxInUnit * (1 - i / gridLines);
|
const v = maxVal * (1 - i / gridLines);
|
||||||
ctx.fillStyle = '#5a6380';
|
const valInUnit = v / finalFactor;
|
||||||
ctx.font = '10px "JetBrains Mono", monospace';
|
ctx.fillStyle = '#5a6380';
|
||||||
ctx.textAlign = 'right';
|
ctx.font = '10px "JetBrains Mono", monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
|
||||||
// Format: "X.X MB/s" or "X MB/s"
|
const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + finalUnitLabel;
|
||||||
const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + unitLabel;
|
ctx.fillText(label, p.left - 10, y + 3);
|
||||||
ctx.fillText(label, p.left - 10, y + 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// X-axis labels (every ~4 hours)
|
// X-axis labels (every ~4 hours)
|
||||||
@@ -227,47 +310,42 @@ class AreaChart {
|
|||||||
const x = getX(i);
|
const x = getX(i);
|
||||||
ctx.fillText(formatTime(timestamps[i]), x, h - 8);
|
ctx.fillText(formatTime(timestamps[i]), x, h - 8);
|
||||||
}
|
}
|
||||||
// Always show last label
|
|
||||||
ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8);
|
ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8);
|
||||||
|
|
||||||
const getPVal = (arr, i) => (arr && i < arr.length) ? arr[i] : 0;
|
// Draw data areas with clipping
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(p.left, p.top, chartW, chartH);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
// Draw TX area
|
|
||||||
if (this.showTx) {
|
if (this.showTx) {
|
||||||
this.drawArea(ctx, tx, this.prevData ? this.prevData.tx : null, getX, getY, chartH, p,
|
this.drawArea(ctx, tx, this.prevData ? this.prevData.tx : null, getX, getY, chartH, p,
|
||||||
'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)',
|
'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)', '#6366f1', len);
|
||||||
'#6366f1', len);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw RX area (on top)
|
|
||||||
if (this.showRx) {
|
if (this.showRx) {
|
||||||
this.drawArea(ctx, rx, this.prevData ? this.prevData.rx : null, getX, getY, chartH, p,
|
this.drawArea(ctx, rx, this.prevData ? this.prevData.rx : null, getX, getY, chartH, p,
|
||||||
'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)',
|
'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', '#06b6d4', len);
|
||||||
'#06b6d4', len);
|
|
||||||
}
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
// Draw P95 line
|
// Draw P95 line
|
||||||
if (this.showP95 && this.p95 && this.animProgress === 1) {
|
if (this.showP95 && this.p95 && (this.animProgress === 1 || this.isDraggingP95)) {
|
||||||
const p95Y = getY(this.p95);
|
const p95Y = getY(this.p95);
|
||||||
// Only draw if within visible range
|
|
||||||
if (p95Y >= p.top && p95Y <= p.top + chartH) {
|
if (p95Y >= p.top && p95Y <= p.top + chartH) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.setLineDash([6, 4]);
|
ctx.setLineDash([6, 4]);
|
||||||
ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)'; // --accent-rose
|
ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.moveTo(p.left, p95Y);
|
ctx.moveTo(p.left, p95Y);
|
||||||
ctx.lineTo(p.left + chartW, p95Y);
|
ctx.lineTo(p.left + chartW, p95Y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// P95 label background
|
|
||||||
const label = '95计费: ' + (window.formatBandwidth ? window.formatBandwidth(this.p95) : this.p95.toFixed(2));
|
const label = '95计费: ' + (window.formatBandwidth ? window.formatBandwidth(this.p95) : this.p95.toFixed(2));
|
||||||
ctx.font = 'bold 11px "JetBrains Mono", monospace';
|
ctx.font = 'bold 11px "JetBrains Mono", monospace';
|
||||||
const metrics = ctx.measureText(label);
|
const metrics = ctx.measureText(label);
|
||||||
ctx.fillStyle = 'rgba(244, 63, 94, 0.15)';
|
ctx.fillStyle = 'rgba(244, 63, 94, 0.15)';
|
||||||
ctx.fillRect(p.left + 8, p95Y - 20, metrics.width + 12, 18);
|
ctx.fillRect(p.left + 8, p95Y - 20, metrics.width + 12, 18);
|
||||||
|
|
||||||
// P95 label text
|
|
||||||
ctx.fillStyle = '#f43f5e';
|
ctx.fillStyle = '#f43f5e';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(label, p.left + 14, p95Y - 7);
|
ctx.fillText(label, p.left + 14, p95Y - 7);
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* Database Integrity Check
|
|
||||||
* Runs at startup to ensure all required tables exist.
|
|
||||||
* Recreates the database if any tables are missing.
|
|
||||||
*/
|
|
||||||
require('dotenv').config();
|
|
||||||
const mysql = require('mysql2/promise');
|
|
||||||
const db = require('./db');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const REQUIRED_TABLES = [
|
|
||||||
'users',
|
|
||||||
'prometheus_sources',
|
|
||||||
'site_settings',
|
|
||||||
'traffic_stats',
|
|
||||||
'server_locations',
|
|
||||||
'latency_routes'
|
|
||||||
];
|
|
||||||
|
|
||||||
async function checkAndFixDatabase() {
|
|
||||||
const envPath = path.join(__dirname, '..', '.env');
|
|
||||||
if (!fs.existsSync(envPath)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check tables
|
|
||||||
const [rows] = await db.query("SHOW TABLES");
|
|
||||||
const existingTables = rows.map(r => Object.values(r)[0]);
|
|
||||||
|
|
||||||
const missingTables = REQUIRED_TABLES.filter(t => !existingTables.includes(t));
|
|
||||||
|
|
||||||
if (missingTables.length > 0) {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing tables: ${missingTables.join(', ')}. Creating them...`);
|
|
||||||
|
|
||||||
for (const table of missingTables) {
|
|
||||||
await createTable(table);
|
|
||||||
}
|
|
||||||
console.log(`[Database Integrity] ✅ Missing tables created.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for is_server_source and type in prometheus_sources
|
|
||||||
const [promColumns] = await db.query("SHOW COLUMNS FROM prometheus_sources");
|
|
||||||
const promColumnNames = promColumns.map(c => c.Field);
|
|
||||||
|
|
||||||
if (!promColumnNames.includes('is_server_source')) {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column 'is_server_source' in 'prometheus_sources'. Adding it...`);
|
|
||||||
await db.query("ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description");
|
|
||||||
console.log(`[Database Integrity] ✅ Column 'is_server_source' added.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!promColumnNames.includes('type')) {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column 'type' in 'prometheus_sources'. Adding it...`);
|
|
||||||
await db.query("ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source");
|
|
||||||
console.log(`[Database Integrity] ✅ Column 'type' added.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for new columns in site_settings
|
|
||||||
const [columns] = await db.query("SHOW COLUMNS FROM site_settings");
|
|
||||||
const columnNames = columns.map(c => c.Field);
|
|
||||||
const addColumn = async (columnName, sql) => {
|
|
||||||
if (!columnNames.includes(columnName)) {
|
|
||||||
try {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column '${columnName}' in 'site_settings'. Adding it...`);
|
|
||||||
await db.query(sql);
|
|
||||||
console.log(`[Database Integrity] ✅ Column '${columnName}' added.`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Database Integrity] ❌ Failed to add column '${columnName}':`, err.message);
|
|
||||||
// Try without AFTER if it exists
|
|
||||||
if (sql.includes('AFTER')) {
|
|
||||||
try {
|
|
||||||
const fallback = sql.split(' AFTER')[0];
|
|
||||||
console.log(`[Database Integrity] 🔄 Retrying column '${columnName}' WITHOUT 'AFTER'...`);
|
|
||||||
await db.query(fallback);
|
|
||||||
console.log(`[Database Integrity] ✅ Column '${columnName}' added via fallback.`);
|
|
||||||
} catch (err2) {
|
|
||||||
console.error(`[Database Integrity] ❌ Fallback also failed:`, err2.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await addColumn('show_page_name', "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name");
|
|
||||||
await addColumn('show_95_bandwidth', "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme");
|
|
||||||
await addColumn('p95_type', "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth");
|
|
||||||
await addColumn('require_login_for_server_details', "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type");
|
|
||||||
await addColumn('blackbox_source_id', "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type");
|
|
||||||
await addColumn('latency_source', "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id");
|
|
||||||
await addColumn('latency_dest', "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source");
|
|
||||||
await addColumn('latency_target', "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest");
|
|
||||||
await addColumn('icp_filing', "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target");
|
|
||||||
await addColumn('ps_filing', "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing");
|
|
||||||
await addColumn('logo_url_dark', "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url");
|
|
||||||
await addColumn('favicon_url', "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark");
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Database Integrity] ❌ Overall site_settings check error:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTable(tableName) {
|
|
||||||
console.log(` - Creating table "${tableName}"...`);
|
|
||||||
switch (tableName) {
|
|
||||||
case 'users':
|
|
||||||
await db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
username VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
password VARCHAR(255) NOT NULL,
|
|
||||||
salt VARCHAR(255) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
break;
|
|
||||||
case 'prometheus_sources':
|
|
||||||
await db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS prometheus_sources (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
url VARCHAR(500) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
break;
|
|
||||||
case 'site_settings':
|
|
||||||
await db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
|
||||||
id INT PRIMARY KEY DEFAULT 1,
|
|
||||||
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
|
||||||
show_page_name TINYINT(1) DEFAULT 1,
|
|
||||||
title VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
|
||||||
logo_url TEXT,
|
|
||||||
logo_url_dark TEXT,
|
|
||||||
favicon_url TEXT,
|
|
||||||
default_theme VARCHAR(20) DEFAULT 'dark',
|
|
||||||
show_95_bandwidth TINYINT(1) DEFAULT 0,
|
|
||||||
p95_type VARCHAR(20) DEFAULT 'tx',
|
|
||||||
require_login_for_server_details TINYINT(1) DEFAULT 1,
|
|
||||||
blackbox_source_id INT,
|
|
||||||
latency_source VARCHAR(100),
|
|
||||||
latency_dest VARCHAR(100),
|
|
||||||
latency_target VARCHAR(255),
|
|
||||||
icp_filing VARCHAR(255),
|
|
||||||
ps_filing VARCHAR(255),
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
await db.query(`
|
|
||||||
INSERT IGNORE INTO site_settings (id, page_name, title, default_theme, show_95_bandwidth)
|
|
||||||
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0)
|
|
||||||
`);
|
|
||||||
break;
|
|
||||||
case 'traffic_stats':
|
|
||||||
await db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS traffic_stats (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
rx_bytes BIGINT UNSIGNED DEFAULT 0,
|
|
||||||
tx_bytes BIGINT UNSIGNED DEFAULT 0,
|
|
||||||
rx_bandwidth DOUBLE DEFAULT 0,
|
|
||||||
tx_bandwidth DOUBLE DEFAULT 0,
|
|
||||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE INDEX (timestamp)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
break;
|
|
||||||
case 'latency_routes':
|
|
||||||
await db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS latency_routes (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
source_id INT NOT NULL,
|
|
||||||
latency_source VARCHAR(100) NOT NULL,
|
|
||||||
latency_dest VARCHAR(100) NOT NULL,
|
|
||||||
latency_target VARCHAR(255) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
break;
|
|
||||||
case 'server_locations':
|
|
||||||
await db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS server_locations (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
ip VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
country CHAR(2),
|
|
||||||
country_name VARCHAR(100),
|
|
||||||
region VARCHAR(100),
|
|
||||||
city VARCHAR(100),
|
|
||||||
latitude DOUBLE,
|
|
||||||
longitude DOUBLE,
|
|
||||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = checkAndFixDatabase;
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
* Database schema check
|
* Database schema check
|
||||||
* Ensures required tables and columns exist at startup.
|
* Ensures required tables and columns exist at startup.
|
||||||
*/
|
*/
|
||||||
require('dotenv').config();
|
|
||||||
const db = require('./db');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
|
const db = require('./db');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const SCHEMA = {
|
const SCHEMA = {
|
||||||
@@ -18,7 +18,11 @@ const SCHEMA = {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
columns: []
|
columns: [
|
||||||
|
{ name: 'username', sql: "ALTER TABLE users ADD COLUMN username VARCHAR(255) NOT NULL UNIQUE AFTER id" },
|
||||||
|
{ name: 'password', sql: "ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL AFTER username" },
|
||||||
|
{ name: 'salt', sql: "ALTER TABLE users ADD COLUMN salt VARCHAR(255) NOT NULL AFTER password" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
prometheus_sources: {
|
prometheus_sources: {
|
||||||
createSql: `
|
createSql: `
|
||||||
@@ -28,29 +32,30 @@ const SCHEMA = {
|
|||||||
url VARCHAR(500) NOT NULL,
|
url VARCHAR(500) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
is_server_source TINYINT(1) DEFAULT 1,
|
is_server_source TINYINT(1) DEFAULT 1,
|
||||||
|
is_overview_source TINYINT(1) DEFAULT 1,
|
||||||
|
is_detail_source TINYINT(1) DEFAULT 1,
|
||||||
type VARCHAR(50) DEFAULT 'prometheus',
|
type VARCHAR(50) DEFAULT 'prometheus',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{ name: 'name', sql: "ALTER TABLE prometheus_sources ADD COLUMN name VARCHAR(255) NOT NULL AFTER id" },
|
||||||
name: 'is_server_source',
|
{ name: 'url', sql: "ALTER TABLE prometheus_sources ADD COLUMN url VARCHAR(500) NOT NULL AFTER name" },
|
||||||
sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description"
|
{ name: 'description', sql: "ALTER TABLE prometheus_sources ADD COLUMN description TEXT AFTER url" },
|
||||||
},
|
{ name: 'is_server_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description" },
|
||||||
{
|
{ name: 'is_overview_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_overview_source TINYINT(1) DEFAULT 1 AFTER is_server_source" },
|
||||||
name: 'type',
|
{ name: 'is_detail_source', sql: "ALTER TABLE prometheus_sources ADD COLUMN is_detail_source TINYINT(1) DEFAULT 1 AFTER is_overview_source" },
|
||||||
sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source"
|
{ name: 'type', sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_detail_source" }
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
site_settings: {
|
site_settings: {
|
||||||
createSql: `
|
createSql: `
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
id INT PRIMARY KEY DEFAULT 1,
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
page_name VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
|
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
||||||
show_page_name TINYINT(1) DEFAULT 1,
|
show_page_name TINYINT(1) DEFAULT 1,
|
||||||
title VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
|
title VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
logo_url_dark TEXT,
|
logo_url_dark TEXT,
|
||||||
favicon_url TEXT,
|
favicon_url TEXT,
|
||||||
@@ -64,6 +69,10 @@ const SCHEMA = {
|
|||||||
latency_target VARCHAR(255),
|
latency_target VARCHAR(255),
|
||||||
icp_filing VARCHAR(255),
|
icp_filing VARCHAR(255),
|
||||||
ps_filing VARCHAR(255),
|
ps_filing VARCHAR(255),
|
||||||
|
show_server_ip TINYINT(1) DEFAULT 0,
|
||||||
|
ip_metric_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
ip_label_name VARCHAR(100) DEFAULT 'address',
|
||||||
|
custom_metrics JSON DEFAULT NULL,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
@@ -71,58 +80,30 @@ const SCHEMA = {
|
|||||||
INSERT IGNORE INTO site_settings (
|
INSERT IGNORE INTO site_settings (
|
||||||
id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details
|
id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details
|
||||||
) VALUES (
|
) VALUES (
|
||||||
1, 'Data Visualization Display Wall', 1, 'Data Visualization Display Wall', 'dark', 0, 'tx', 1
|
1, '数据可视化展示大屏', 1, '数据可视化展示大屏', 'dark', 0, 'tx', 1
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{ name: 'page_name', sql: "ALTER TABLE site_settings ADD COLUMN page_name VARCHAR(255) DEFAULT '数据可视化展示大屏' AFTER id" },
|
||||||
name: 'show_page_name',
|
{ name: 'show_page_name', sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name" },
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name"
|
{ name: 'title', sql: "ALTER TABLE site_settings ADD COLUMN title VARCHAR(255) DEFAULT '数据可视化展示大屏' AFTER show_page_name" },
|
||||||
},
|
{ name: 'logo_url', sql: "ALTER TABLE site_settings ADD COLUMN logo_url TEXT AFTER title" },
|
||||||
{
|
{ name: 'logo_url_dark', sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url" },
|
||||||
name: 'logo_url_dark',
|
{ name: 'favicon_url', sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark" },
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url"
|
{ name: 'default_theme', sql: "ALTER TABLE site_settings ADD COLUMN default_theme VARCHAR(20) DEFAULT 'dark' AFTER favicon_url" },
|
||||||
},
|
{ name: 'show_95_bandwidth', sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme" },
|
||||||
{
|
{ name: 'p95_type', sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth" },
|
||||||
name: 'favicon_url',
|
{ name: 'require_login_for_server_details', sql: "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type" },
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark"
|
{ name: 'blackbox_source_id', sql: "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER require_login_for_server_details" },
|
||||||
},
|
{ name: 'latency_source', sql: "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id" },
|
||||||
{
|
{ name: 'latency_dest', sql: "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source" },
|
||||||
name: 'show_95_bandwidth',
|
{ name: 'latency_target', sql: "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest" },
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme"
|
{ name: 'icp_filing', sql: "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target" },
|
||||||
},
|
{ name: 'ps_filing', sql: "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing" },
|
||||||
{
|
{ name: 'show_server_ip', sql: "ALTER TABLE site_settings ADD COLUMN show_server_ip TINYINT(1) DEFAULT 0 AFTER ps_filing" },
|
||||||
name: 'p95_type',
|
{ name: 'ip_metric_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_metric_name VARCHAR(100) DEFAULT NULL AFTER show_server_ip" },
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"
|
{ name: 'ip_label_name', sql: "ALTER TABLE site_settings ADD COLUMN ip_label_name VARCHAR(100) DEFAULT 'address' AFTER ip_metric_name" },
|
||||||
},
|
{ name: 'custom_metrics', sql: "ALTER TABLE site_settings ADD COLUMN custom_metrics JSON DEFAULT NULL AFTER ip_label_name" }
|
||||||
{
|
|
||||||
name: 'require_login_for_server_details',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'blackbox_source_id',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER require_login_for_server_details"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'latency_source',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'latency_dest',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'latency_target',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'icp_filing',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ps_filing',
|
|
||||||
sql: "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
traffic_stats: {
|
traffic_stats: {
|
||||||
@@ -137,7 +118,12 @@ const SCHEMA = {
|
|||||||
UNIQUE INDEX (timestamp)
|
UNIQUE INDEX (timestamp)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
columns: []
|
columns: [
|
||||||
|
{ name: 'rx_bytes', sql: "ALTER TABLE traffic_stats ADD COLUMN rx_bytes BIGINT UNSIGNED DEFAULT 0 AFTER id" },
|
||||||
|
{ name: 'tx_bytes', sql: "ALTER TABLE traffic_stats ADD COLUMN tx_bytes BIGINT UNSIGNED DEFAULT 0 AFTER rx_bytes" },
|
||||||
|
{ name: 'rx_bandwidth', sql: "ALTER TABLE traffic_stats ADD COLUMN rx_bandwidth DOUBLE DEFAULT 0 AFTER tx_bytes" },
|
||||||
|
{ name: 'tx_bandwidth', sql: "ALTER TABLE traffic_stats ADD COLUMN tx_bandwidth DOUBLE DEFAULT 0 AFTER rx_bandwidth" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
server_locations: {
|
server_locations: {
|
||||||
createSql: `
|
createSql: `
|
||||||
@@ -153,7 +139,15 @@ const SCHEMA = {
|
|||||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
columns: []
|
columns: [
|
||||||
|
{ name: 'ip', sql: "ALTER TABLE server_locations ADD COLUMN ip VARCHAR(255) NOT NULL UNIQUE AFTER id" },
|
||||||
|
{ name: 'country', sql: "ALTER TABLE server_locations ADD COLUMN country CHAR(2) AFTER ip" },
|
||||||
|
{ name: 'country_name', sql: "ALTER TABLE server_locations ADD COLUMN country_name VARCHAR(100) AFTER country" },
|
||||||
|
{ name: 'region', sql: "ALTER TABLE server_locations ADD COLUMN region VARCHAR(100) AFTER country_name" },
|
||||||
|
{ name: 'city', sql: "ALTER TABLE server_locations ADD COLUMN city VARCHAR(100) AFTER region" },
|
||||||
|
{ name: 'latitude', sql: "ALTER TABLE server_locations ADD COLUMN latitude DOUBLE AFTER city" },
|
||||||
|
{ name: 'longitude', sql: "ALTER TABLE server_locations ADD COLUMN longitude DOUBLE AFTER latitude" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
latency_routes: {
|
latency_routes: {
|
||||||
createSql: `
|
createSql: `
|
||||||
@@ -166,59 +160,74 @@ const SCHEMA = {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
columns: []
|
columns: [
|
||||||
|
{ name: 'source_id', sql: "ALTER TABLE latency_routes ADD COLUMN source_id INT NOT NULL AFTER id" },
|
||||||
|
{ name: 'latency_source', sql: "ALTER TABLE latency_routes ADD COLUMN latency_source VARCHAR(100) NOT NULL AFTER source_id" },
|
||||||
|
{ name: 'latency_dest', sql: "ALTER TABLE latency_routes ADD COLUMN latency_dest VARCHAR(100) NOT NULL AFTER latency_source" },
|
||||||
|
{ name: 'latency_target', sql: "ALTER TABLE latency_routes ADD COLUMN latency_target VARCHAR(255) NOT NULL AFTER latency_dest" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function addColumnIfMissing(tableName, existingColumns, column) {
|
async function ensureTable(tableName, tableSchema) {
|
||||||
if (existingColumns.has(column.name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`);
|
// 1. Ensure table exists
|
||||||
await db.query(column.sql);
|
await db.query(tableSchema.createSql);
|
||||||
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}'.`);
|
|
||||||
} catch (err) {
|
// 2. Check columns
|
||||||
console.error(`[Database Integrity] Failed to add '${tableName}.${column.name}':`, err.message);
|
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)`);
|
||||||
|
|
||||||
if (column.sql.includes(' AFTER ')) {
|
for (const column of tableSchema.columns || []) {
|
||||||
try {
|
if (!existingColumns.has(column.name)) {
|
||||||
const fallbackSql = column.sql.split(' AFTER ')[0];
|
console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`);
|
||||||
await db.query(fallbackSql);
|
await db.query(column.sql);
|
||||||
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}' via fallback.`);
|
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}'.`);
|
||||||
} catch (fallbackErr) {
|
|
||||||
console.error(`[Database Integrity] Fallback failed for '${tableName}.${column.name}':`, fallbackErr.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureTable(tableName, tableSchema) {
|
// 3. Seed data
|
||||||
await db.query(tableSchema.createSql);
|
if (tableSchema.seedSql) {
|
||||||
|
const [rows] = await db.query(`SELECT count(*) as count FROM \`${tableName}\``);
|
||||||
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
|
if (rows[0].count === 0) {
|
||||||
const existingColumns = new Set(columns.map((column) => column.Field));
|
console.log(`[Database Integrity] Table '${tableName}' is empty. Seeding initial data...`);
|
||||||
|
await db.query(tableSchema.seedSql);
|
||||||
for (const column of tableSchema.columns || []) {
|
}
|
||||||
await addColumnIfMissing(tableName, existingColumns, column);
|
}
|
||||||
}
|
} catch (err) {
|
||||||
|
console.error(`[Database Integrity] Error ensuring table '${tableName}':`, err.message);
|
||||||
if (tableSchema.seedSql) {
|
throw err;
|
||||||
await db.query(tableSchema.seedSql);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndFixDatabase() {
|
async function checkAndFixDatabase() {
|
||||||
const envPath = path.join(__dirname, '..', '.env');
|
console.log('[Database Integrity] Starting comprehensive database audit...');
|
||||||
if (!fs.existsSync(envPath)) return;
|
|
||||||
|
// Try to check if we can even connect
|
||||||
|
try {
|
||||||
|
const health = await db.checkHealth();
|
||||||
|
if (health.status !== 'up') {
|
||||||
|
console.warn(`[Database Integrity] initial health check failed: ${health.error}`);
|
||||||
|
// If we can't connect, maybe the DB itself doesn't exist?
|
||||||
|
// For now, we rely on the pool to handle connection retries/errors.
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore health check errors, let ensureTable handle the primary queries
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let tablesChecked = 0;
|
||||||
for (const [tableName, tableSchema] of Object.entries(SCHEMA)) {
|
for (const [tableName, tableSchema] of Object.entries(SCHEMA)) {
|
||||||
await ensureTable(tableName, tableSchema);
|
await ensureTable(tableName, tableSchema);
|
||||||
|
tablesChecked++;
|
||||||
}
|
}
|
||||||
|
console.log(`[Database Integrity] Audit complete. ${tablesChecked} tables verified and healthy.`);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Database Integrity] Startup schema check failed:', err.message);
|
console.error('[Database Integrity] ❌ Audit failed:', err.message);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,21 +18,48 @@ const enableExternalGeoLookup = process.env.ENABLE_EXTERNAL_GEO_LOOKUP === 'true
|
|||||||
function normalizeGeo(geo) {
|
function normalizeGeo(geo) {
|
||||||
if (!geo) return geo;
|
if (!geo) return geo;
|
||||||
|
|
||||||
// Custom normalization for TW, HK, MO to "China, {CODE}"
|
// Custom normalization for TW to "Taipei, China" and JP to "Tokyo"
|
||||||
const specialRegions = ['TW'];
|
const country = (geo.country || geo.country_code || '').toUpperCase();
|
||||||
if (specialRegions.includes(geo.country?.toUpperCase())) {
|
if (country === 'TW') {
|
||||||
return {
|
return {
|
||||||
...geo,
|
...geo,
|
||||||
city: `China, ${geo.country.toUpperCase()}`,
|
city: 'Taipei',
|
||||||
country_name: 'China'
|
country: 'TW',
|
||||||
|
country_name: 'China',
|
||||||
|
// Force Taipei coordinates for consistent 2D plotting
|
||||||
|
loc: '25.0330,121.5654',
|
||||||
|
latitude: 25.0330,
|
||||||
|
longitude: 121.5654
|
||||||
|
};
|
||||||
|
} else if (country === 'JP') {
|
||||||
|
return {
|
||||||
|
...geo,
|
||||||
|
city: 'Tokyo',
|
||||||
|
country: 'JP',
|
||||||
|
country_name: 'Japan',
|
||||||
|
// Force Tokyo coordinates for consistent 2D plotting
|
||||||
|
loc: '35.6895,139.6917',
|
||||||
|
latitude: 35.6895,
|
||||||
|
longitude: 139.6917
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocation(target) {
|
async function getLocation(target) {
|
||||||
// Normalize target (strip port if present)
|
// Normalize target (strip port if present, handle IPv6 brackets)
|
||||||
const cleanTarget = target.split(':')[0];
|
let cleanTarget = target;
|
||||||
|
if (cleanTarget.startsWith('[')) {
|
||||||
|
const closingBracket = cleanTarget.indexOf(']');
|
||||||
|
if (closingBracket !== -1) {
|
||||||
|
cleanTarget = cleanTarget.substring(1, closingBracket);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parts = cleanTarget.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
cleanTarget = parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check if we already have this IP/Domain in DB (FASTEST)
|
// 1. Check if we already have this IP/Domain in DB (FASTEST)
|
||||||
try {
|
try {
|
||||||
@@ -58,7 +85,18 @@ async function getLocation(target) {
|
|||||||
// Secondary DB check with resolved IP
|
// Secondary DB check with resolved IP
|
||||||
const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]);
|
const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]);
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
return normalizeGeo(rows[0]);
|
const data = rows[0];
|
||||||
|
// Cache the domain mapping to avoid future DNS lookups
|
||||||
|
if (cleanTarget !== cleanIp) {
|
||||||
|
try {
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE last_updated = CURRENT_TIMESTAMP
|
||||||
|
`, [cleanTarget, data.country, data.country_name, data.region, data.city, data.latitude, data.longitude]);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return normalizeGeo(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Quiet DNS failure for tokens (legacy bug mitigation)
|
// Quiet DNS failure for tokens (legacy bug mitigation)
|
||||||
@@ -118,6 +156,29 @@ async function getLocation(target) {
|
|||||||
locationData.longitude
|
locationData.longitude
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Cache the domain target as well if it differs from the resolved IP
|
||||||
|
if (cleanTarget !== cleanIp) {
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
country = VALUES(country),
|
||||||
|
country_name = VALUES(country_name),
|
||||||
|
region = VALUES(region),
|
||||||
|
city = VALUES(city),
|
||||||
|
latitude = VALUES(latitude),
|
||||||
|
longitude = VALUES(longitude)
|
||||||
|
`, [
|
||||||
|
cleanTarget,
|
||||||
|
locationData.country,
|
||||||
|
locationData.country_name,
|
||||||
|
locationData.region,
|
||||||
|
locationData.city,
|
||||||
|
locationData.latitude,
|
||||||
|
locationData.longitude
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return locationData;
|
return locationData;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
1985
server/index.js
1985
server/index.js
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,40 @@
|
|||||||
/**
|
const path = require('path');
|
||||||
* Database Initialization Script
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
* Run: npm run init-db
|
|
||||||
* Creates the required MySQL database and tables.
|
|
||||||
*/
|
|
||||||
require('dotenv').config();
|
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
|
const checkAndFixDatabase = require('./db-schema-check');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
async function initDatabase() {
|
async function initDatabase() {
|
||||||
const connection = await mysql.createConnection({
|
const host = process.env.MYSQL_HOST || 'localhost';
|
||||||
host: process.env.MYSQL_HOST || 'localhost',
|
const port = parseInt(process.env.MYSQL_PORT) || 3306;
|
||||||
port: parseInt(process.env.MYSQL_PORT) || 3306,
|
const user = process.env.MYSQL_USER || 'root';
|
||||||
user: process.env.MYSQL_USER || 'root',
|
const password = process.env.MYSQL_PASSWORD || '';
|
||||||
password: process.env.MYSQL_PASSWORD || ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const dbName = process.env.MYSQL_DATABASE || 'display_wall';
|
const dbName = process.env.MYSQL_DATABASE || 'display_wall';
|
||||||
|
|
||||||
console.log('🔧 Initializing database...\n');
|
// 1. Create connection without database selected to create the DB itself
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔧 Initializing database environment...\n');
|
||||||
|
|
||||||
// Create database
|
// Create database
|
||||||
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
console.log(` ✅ Database "${dbName}" ready`);
|
console.log(` ✅ Database "${dbName}" ready`);
|
||||||
|
await connection.end();
|
||||||
|
|
||||||
await connection.query(`USE \`${dbName}\``);
|
// 2. Re-initialize the standard pool so it can see the new DB
|
||||||
|
db.initPool();
|
||||||
|
|
||||||
// Create users table
|
// 3. Use the centralized schema tool to create/fix all tables
|
||||||
await connection.query(`
|
console.log(' 📦 Initializing tables using schema-check tool...');
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await checkAndFixDatabase();
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
console.log(' ✅ Tables and columns ready');
|
||||||
username VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
password VARCHAR(255) NOT NULL,
|
|
||||||
salt VARCHAR(255) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
console.log(' ✅ Table "users" ready');
|
|
||||||
|
|
||||||
// Create prometheus_sources table
|
|
||||||
await connection.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS prometheus_sources (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
url VARCHAR(500) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
console.log(' ✅ Table "prometheus_sources" ready');
|
|
||||||
|
|
||||||
// Create site_settings table
|
|
||||||
await connection.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
|
||||||
id INT PRIMARY KEY DEFAULT 1,
|
|
||||||
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
|
||||||
title VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
|
||||||
logo_url TEXT,
|
|
||||||
default_theme VARCHAR(20) DEFAULT 'dark',
|
|
||||||
show_page_name TINYINT(1) DEFAULT 1,
|
|
||||||
logo_url_dark TEXT,
|
|
||||||
favicon_url TEXT,
|
|
||||||
show_95_bandwidth TINYINT(1) DEFAULT 0,
|
|
||||||
p95_type VARCHAR(20) DEFAULT 'tx',
|
|
||||||
require_login_for_server_details TINYINT(1) DEFAULT 1,
|
|
||||||
blackbox_source_id INT,
|
|
||||||
latency_source VARCHAR(100),
|
|
||||||
latency_dest VARCHAR(100),
|
|
||||||
latency_target VARCHAR(255),
|
|
||||||
icp_filing VARCHAR(255),
|
|
||||||
ps_filing VARCHAR(255),
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
// Insert default settings if not exists
|
|
||||||
await connection.query(`
|
|
||||||
INSERT IGNORE INTO site_settings (id, page_name, title, default_theme)
|
|
||||||
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark')
|
|
||||||
`);
|
|
||||||
console.log(' ✅ Table "site_settings" ready');
|
|
||||||
|
|
||||||
// Create server_locations table
|
|
||||||
await connection.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS server_locations (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
ip VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
country CHAR(2),
|
|
||||||
country_name VARCHAR(100),
|
|
||||||
region VARCHAR(100),
|
|
||||||
city VARCHAR(100),
|
|
||||||
latitude DOUBLE,
|
|
||||||
longitude DOUBLE,
|
|
||||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
console.log(' ✅ Table "server_locations" ready');
|
|
||||||
|
|
||||||
console.log('\n🎉 Database initialization complete!\n');
|
console.log('\n🎉 Database initialization complete!\n');
|
||||||
await connection.end();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initDatabase().catch(err => {
|
initDatabase().catch(err => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const cache = require('./cache'); // <-- ADD
|
||||||
|
|
||||||
const QUERY_TIMEOUT = 10000;
|
const QUERY_TIMEOUT = 10000;
|
||||||
|
|
||||||
@@ -9,14 +10,34 @@ const crypto = require('crypto');
|
|||||||
const httpAgent = new http.Agent({ keepAlive: true });
|
const httpAgent = new http.Agent({ keepAlive: true });
|
||||||
const httpsAgent = new https.Agent({ keepAlive: true });
|
const httpsAgent = new https.Agent({ keepAlive: true });
|
||||||
|
|
||||||
const serverIdMap = new Map(); // token -> { instance, job, source }
|
const serverIdMap = new Map(); // token -> { instance, job, source, lastSeen }
|
||||||
const SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex');
|
|
||||||
|
function getSecret() {
|
||||||
|
// Use the env variable populated by index.js initialization
|
||||||
|
return process.env.APP_SECRET || 'fallback-secret-for-safety';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of serverIdMap to prevent infinite growth
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
for (const [token, data] of serverIdMap.entries()) {
|
||||||
|
if (now - (data.lastSeen || 0) > TTL) {
|
||||||
|
serverIdMap.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3600000); // Once per hour
|
||||||
|
|
||||||
function getServerToken(instance, job, source) {
|
function getServerToken(instance, job, source) {
|
||||||
const hash = crypto.createHmac('sha256', SECRET)
|
const hash = crypto.createHmac('sha256', getSecret())
|
||||||
.update(`${instance}:${job}:${source}`)
|
.update(`${instance}:${job}:${source}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.substring(0, 16);
|
.substring(0, 16);
|
||||||
|
|
||||||
|
// Update lastSeen timestamp
|
||||||
|
const data = serverIdMap.get(hash);
|
||||||
|
if (data) data.lastSeen = Date.now();
|
||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +69,12 @@ function createClient(baseUrl) {
|
|||||||
/**
|
/**
|
||||||
* Test Prometheus connection
|
* Test Prometheus connection
|
||||||
*/
|
*/
|
||||||
async function testConnection(url) {
|
async function testConnection(url, customTimeout = null) {
|
||||||
const normalized = normalizeUrl(url);
|
const normalized = normalizeUrl(url);
|
||||||
try {
|
try {
|
||||||
// Using native fetch to avoid follow-redirects/axios "protocol mismatch" issues in some Node environments
|
// Using native fetch to avoid follow-redirects/axios "protocol mismatch" issues in some Node environments
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), QUERY_TIMEOUT);
|
const timer = setTimeout(() => controller.abort(), customTimeout || QUERY_TIMEOUT);
|
||||||
|
|
||||||
// Node native fetch - handles http/https automatically
|
// Node native fetch - handles http/https automatically
|
||||||
const res = await fetch(`${normalized}/api/v1/status/buildinfo`, {
|
const res = await fetch(`${normalized}/api/v1/status/buildinfo`, {
|
||||||
@@ -188,7 +209,11 @@ async function getOverviewMetrics(url, sourceName) {
|
|||||||
diskFreeResult,
|
diskFreeResult,
|
||||||
netRxResult,
|
netRxResult,
|
||||||
netTxResult,
|
netTxResult,
|
||||||
targetsResult
|
netRx24hResult,
|
||||||
|
netTx24hResult,
|
||||||
|
targetsResult,
|
||||||
|
conntrackEntriesResult,
|
||||||
|
conntrackLimitResult
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
// CPU usage per instance: 1 - avg idle
|
// CPU usage per instance: 1 - avg idle
|
||||||
query(url, '100 - (avg by (instance, job) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)').catch(() => []),
|
query(url, '100 - (avg by (instance, job) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)').catch(() => []),
|
||||||
@@ -206,8 +231,16 @@ async function getOverviewMetrics(url, sourceName) {
|
|||||||
query(url, 'sum by (instance, job) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
|
query(url, 'sum by (instance, job) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
|
||||||
// Network transmit rate (bytes/sec)
|
// Network transmit rate (bytes/sec)
|
||||||
query(url, 'sum by (instance, job) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
|
query(url, 'sum by (instance, job) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))').catch(() => []),
|
||||||
|
// 24h Network receive total (bytes)
|
||||||
|
query(url, 'sum by (instance, job) (increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
|
||||||
|
// 24h Network transmit total (bytes)
|
||||||
|
query(url, 'sum by (instance, job) (increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
|
||||||
// Targets status from /api/v1/targets
|
// Targets status from /api/v1/targets
|
||||||
getTargets(url).catch(() => [])
|
getTargets(url).catch(() => []),
|
||||||
|
// Conntrack entries
|
||||||
|
query(url, 'node_nf_conntrack_entries').catch(() => []),
|
||||||
|
// Conntrack limits
|
||||||
|
query(url, 'node_nf_conntrack_entries_limit').catch(() => [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch 24h detailed traffic using the A*duration logic
|
// Fetch 24h detailed traffic using the A*duration logic
|
||||||
@@ -222,7 +255,10 @@ async function getOverviewMetrics(url, sourceName) {
|
|||||||
const token = getServerToken(originalInstance, job, sourceName);
|
const token = getServerToken(originalInstance, job, sourceName);
|
||||||
|
|
||||||
// Store mapping for detail queries
|
// Store mapping for detail queries
|
||||||
serverIdMap.set(token, { instance: originalInstance, source: sourceName, job });
|
serverIdMap.set(token, { instance: originalInstance, source: sourceName, job, lastSeen: Date.now() });
|
||||||
|
|
||||||
|
// Also store in Valkey for resilience across restarts
|
||||||
|
cache.set(`server_token:${token}`, originalInstance, 86400).catch(()=>{});
|
||||||
|
|
||||||
if (!instances.has(token)) {
|
if (!instances.has(token)) {
|
||||||
instances.set(token, {
|
instances.set(token, {
|
||||||
@@ -238,9 +274,14 @@ async function getOverviewMetrics(url, sourceName) {
|
|||||||
diskUsed: 0,
|
diskUsed: 0,
|
||||||
netRx: 0,
|
netRx: 0,
|
||||||
netTx: 0,
|
netTx: 0,
|
||||||
|
traffic24hRx: 0,
|
||||||
|
traffic24hTx: 0,
|
||||||
|
conntrackEntries: 0,
|
||||||
|
conntrackLimit: 0,
|
||||||
up: false,
|
up: false,
|
||||||
memPercent: 0,
|
memPercent: 0,
|
||||||
diskPercent: 0
|
diskPercent: 0,
|
||||||
|
conntrackPercent: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const inst = instances.get(token);
|
const inst = instances.get(token);
|
||||||
@@ -306,6 +347,26 @@ async function getOverviewMetrics(url, sourceName) {
|
|||||||
inst.netTx = parseFloat(r.value[1]) || 0;
|
inst.netTx = parseFloat(r.value[1]) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse 24h traffic
|
||||||
|
for (const r of netRx24hResult) {
|
||||||
|
const inst = getOrCreate(r.metric);
|
||||||
|
inst.traffic24hRx = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
for (const r of netTx24hResult) {
|
||||||
|
const inst = getOrCreate(r.metric);
|
||||||
|
inst.traffic24hTx = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse conntrack
|
||||||
|
for (const r of conntrackEntriesResult) {
|
||||||
|
const inst = getOrCreate(r.metric);
|
||||||
|
inst.conntrackEntries = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
for (const r of conntrackLimitResult) {
|
||||||
|
const inst = getOrCreate(r.metric);
|
||||||
|
inst.conntrackLimit = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
for (const inst of instances.values()) {
|
for (const inst of instances.values()) {
|
||||||
if (!inst.up && (inst.cpuPercent > 0 || inst.memTotal > 0)) {
|
if (!inst.up && (inst.cpuPercent > 0 || inst.memTotal > 0)) {
|
||||||
inst.up = true;
|
inst.up = true;
|
||||||
@@ -313,6 +374,7 @@ async function getOverviewMetrics(url, sourceName) {
|
|||||||
// Calculate percentages on backend
|
// Calculate percentages on backend
|
||||||
inst.memPercent = inst.memTotal > 0 ? (inst.memUsed / inst.memTotal * 100) : 0;
|
inst.memPercent = inst.memTotal > 0 ? (inst.memUsed / inst.memTotal * 100) : 0;
|
||||||
inst.diskPercent = inst.diskTotal > 0 ? (inst.diskUsed / inst.diskTotal * 100) : 0;
|
inst.diskPercent = inst.diskTotal > 0 ? (inst.diskUsed / inst.diskTotal * 100) : 0;
|
||||||
|
inst.conntrackPercent = inst.conntrackLimit > 0 ? (inst.conntrackEntries / inst.conntrackLimit * 100) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allInstancesList = Array.from(instances.values());
|
const allInstancesList = Array.from(instances.values());
|
||||||
@@ -391,25 +453,23 @@ function calculateTrafficFromHistory(values) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total traffic for the past 24h by fetching all points and integrating
|
* Get total traffic for the past 24h using Prometheus increase() for stability and accuracy
|
||||||
*/
|
*/
|
||||||
async function get24hTrafficSum(url) {
|
async function get24hTrafficSum(url) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
try {
|
||||||
const start = now - 86400;
|
const [rxResult, txResult] = await Promise.all([
|
||||||
const step = 60; // 1-minute points for calculation
|
query(url, 'sum(increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
|
||||||
|
query(url, 'sum(increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => [])
|
||||||
|
]);
|
||||||
|
|
||||||
const [rxResult, txResult] = await Promise.all([
|
const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0;
|
||||||
queryRange(url, 'sum(rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))', start, now, step).catch(() => []),
|
const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0;
|
||||||
queryRange(url, 'sum(rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[1m]))', start, now, step).catch(() => [])
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
|
return { rx, tx };
|
||||||
const txValues = txResult.length > 0 ? txResult[0].values : [];
|
} catch (err) {
|
||||||
|
console.error(`[Prometheus] get24hTrafficSum error:`, err.message);
|
||||||
return {
|
return { rx: 0, tx: 0 };
|
||||||
rx: calculateTrafficFromHistory(rxValues),
|
}
|
||||||
tx: calculateTrafficFromHistory(txValues)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -417,34 +477,28 @@ async function get24hTrafficSum(url) {
|
|||||||
*/
|
*/
|
||||||
async function get24hServerTrafficSum(url, instance, job) {
|
async function get24hServerTrafficSum(url, instance, job) {
|
||||||
const node = resolveToken(instance);
|
const node = resolveToken(instance);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const start = now - 86400;
|
const 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 step = 60;
|
const txExpr = `sum(increase(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[24h]))`;
|
||||||
|
|
||||||
const rxExpr = `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`;
|
|
||||||
const txExpr = `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`;
|
|
||||||
|
|
||||||
const [rxResult, txResult] = await Promise.all([
|
const [rxResult, txResult] = await Promise.all([
|
||||||
queryRange(url, rxExpr, start, now, step).catch(() => []),
|
query(url, rxExpr).catch(() => []),
|
||||||
queryRange(url, txExpr, start, now, step).catch(() => [])
|
query(url, txExpr).catch(() => [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
|
const rx = rxResult.length > 0 ? parseFloat(rxResult[0].value[1]) : 0;
|
||||||
const txValues = txResult.length > 0 ? txResult[0].values : [];
|
const tx = txResult.length > 0 ? parseFloat(txResult[0].value[1]) : 0;
|
||||||
|
|
||||||
return {
|
return { rx, tx };
|
||||||
rx: calculateTrafficFromHistory(rxValues),
|
|
||||||
tx: calculateTrafficFromHistory(txValues)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get network traffic history (past 24h, 5-min intervals for chart)
|
* Get network traffic history (past 24h, 5-min intervals for chart)
|
||||||
*/
|
*/
|
||||||
async function getNetworkHistory(url) {
|
async function getNetworkHistory(url) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const start = now - 86400; // 24h ago
|
|
||||||
const step = 300; // 5 minutes for better resolution on chart
|
const step = 300; // 5 minutes for better resolution on chart
|
||||||
|
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
|
||||||
|
const start = now - 86400; // 24h ago
|
||||||
|
|
||||||
const [rxResult, txResult] = await Promise.all([
|
const [rxResult, txResult] = await Promise.all([
|
||||||
queryRange(url,
|
queryRange(url,
|
||||||
@@ -496,9 +550,9 @@ function mergeNetworkHistories(histories) {
|
|||||||
* Get CPU usage history (past 1h, 1-min intervals)
|
* Get CPU usage history (past 1h, 1-min intervals)
|
||||||
*/
|
*/
|
||||||
async function getCpuHistory(url) {
|
async function getCpuHistory(url) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const start = now - 3600; // 1h ago
|
|
||||||
const step = 60; // 1 minute
|
const step = 60; // 1 minute
|
||||||
|
const now = Math.floor(Date.now() / 1000 / step) * step; // Sync to step boundary
|
||||||
|
const start = now - 3600; // 1h ago
|
||||||
|
|
||||||
const result = await queryRange(url,
|
const result = await queryRange(url,
|
||||||
'100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)',
|
'100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)',
|
||||||
@@ -533,19 +587,22 @@ function mergeCpuHistories(histories) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function resolveToken(token) {
|
async function resolveToken(token) {
|
||||||
if (serverIdMap.has(token)) {
|
if (serverIdMap.has(token)) {
|
||||||
return serverIdMap.get(token).instance;
|
return serverIdMap.get(token).instance;
|
||||||
}
|
}
|
||||||
|
const cachedInstance = await cache.get(`server_token:${token}`);
|
||||||
|
if (cachedInstance) return cachedInstance;
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get detailed metrics for a specific server (node)
|
* Get detailed metrics for a specific server (node)
|
||||||
*/
|
*/
|
||||||
async function getServerDetails(baseUrl, instance, job) {
|
async function getServerDetails(baseUrl, instance, job, settings = {}) {
|
||||||
const url = normalizeUrl(baseUrl);
|
const url = normalizeUrl(baseUrl);
|
||||||
const node = resolveToken(instance);
|
const node = await resolveToken(instance);
|
||||||
|
|
||||||
// Queries based on the requested dashboard structure
|
// Queries based on the requested dashboard structure
|
||||||
const queries = {
|
const queries = {
|
||||||
@@ -565,6 +622,9 @@ async function getServerDetails(baseUrl, instance, job) {
|
|||||||
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
|
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
|
||||||
sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
|
sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
|
||||||
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`,
|
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`,
|
||||||
|
conntrackEntries: `node_nf_conntrack_entries{instance="${node}",job="${job}"}`,
|
||||||
|
conntrackLimit: `node_nf_conntrack_entries_limit{instance="${node}",job="${job}"}`,
|
||||||
|
conntrackUsedPct: `(node_nf_conntrack_entries{instance="${node}",job="${job}"} / node_nf_conntrack_entries_limit{instance="${node}",job="${job}"}) * 100`,
|
||||||
// Get individual partitions (excluding virtual and FUSE mounts)
|
// Get individual partitions (excluding virtual and FUSE mounts)
|
||||||
partitions_size: `node_filesystem_size_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`,
|
partitions_size: `node_filesystem_size_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`,
|
||||||
partitions_free: `node_filesystem_free_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`
|
partitions_free: `node_filesystem_free_bytes{instance="${node}", job="${job}", fstype!~"tmpfs|autofs|proc|sysfs|fuse.*", mountpoint!~"/tmp.*|/var/lib/docker/.*|/run/.*"}`
|
||||||
@@ -590,6 +650,85 @@ async function getServerDetails(baseUrl, instance, job) {
|
|||||||
|
|
||||||
await Promise.all(queryPromises);
|
await Promise.all(queryPromises);
|
||||||
|
|
||||||
|
// Process custom metrics from settings
|
||||||
|
results.custom_data = [];
|
||||||
|
try {
|
||||||
|
const customMetrics = typeof settings.custom_metrics === 'string'
|
||||||
|
? JSON.parse(settings.custom_metrics)
|
||||||
|
: (settings.custom_metrics || []);
|
||||||
|
|
||||||
|
if (Array.isArray(customMetrics) && customMetrics.length > 0) {
|
||||||
|
const customPromises = customMetrics.map(async (cfg) => {
|
||||||
|
if (!cfg.metric) return null;
|
||||||
|
try {
|
||||||
|
const expr = `${cfg.metric}{instance="${node}",job="${job}"}`;
|
||||||
|
const res = await query(url, expr);
|
||||||
|
if (res && res.length > 0) {
|
||||||
|
const val = res[0].metric[cfg.label || 'address'] || res[0].value[1];
|
||||||
|
|
||||||
|
// If this metric is marked as an IP source, update the main IP fields
|
||||||
|
if (cfg.is_ip && !results.ipv4?.length && !results.ipv6?.length) {
|
||||||
|
if (val.includes(':')) {
|
||||||
|
results.ipv6 = [val];
|
||||||
|
results.ipv4 = [];
|
||||||
|
} else {
|
||||||
|
results.ipv4 = [val];
|
||||||
|
results.ipv6 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: cfg.name || cfg.metric,
|
||||||
|
value: val
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Prometheus] Custom metric error (${cfg.metric}):`, e.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const customResults = await Promise.all(customPromises);
|
||||||
|
results.custom_data = customResults.filter(r => r !== null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Prometheus] Error processing custom metrics:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure IP discovery fallback if no custom IP metric found
|
||||||
|
if ((!results.ipv4 || results.ipv4.length === 0) && (!results.ipv6 || results.ipv6.length === 0)) {
|
||||||
|
try {
|
||||||
|
const targets = await getTargets(baseUrl);
|
||||||
|
const matchedTarget = targets.find(t => t.labels && t.labels.instance === node && t.labels.job === job);
|
||||||
|
if (matchedTarget) {
|
||||||
|
const scrapeUrl = matchedTarget.scrapeUrl || '';
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(scrapeUrl);
|
||||||
|
const host = urlObj.hostname;
|
||||||
|
if (host.includes(':')) {
|
||||||
|
results.ipv6 = [host];
|
||||||
|
results.ipv4 = [];
|
||||||
|
} else {
|
||||||
|
results.ipv4 = [host];
|
||||||
|
results.ipv6 = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const host = scrapeUrl.split('//').pop().split('/')[0].split(':')[0];
|
||||||
|
if (host) {
|
||||||
|
results.ipv4 = [host];
|
||||||
|
results.ipv6 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Prometheus] Target fallback error for ${node}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final sanitization
|
||||||
|
results.ipv4 = results.ipv4 || [];
|
||||||
|
results.ipv6 = results.ipv6 || [];
|
||||||
|
|
||||||
// Group partitions
|
// Group partitions
|
||||||
const partitionsMap = {};
|
const partitionsMap = {};
|
||||||
(results.partitions_size || []).forEach(p => {
|
(results.partitions_size || []).forEach(p => {
|
||||||
@@ -630,7 +769,7 @@ async function getServerDetails(baseUrl, instance, job) {
|
|||||||
*/
|
*/
|
||||||
async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') {
|
async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') {
|
||||||
const url = normalizeUrl(baseUrl);
|
const url = normalizeUrl(baseUrl);
|
||||||
const node = resolveToken(instance);
|
const node = await resolveToken(instance);
|
||||||
|
|
||||||
// CPU Busy history: 100 - idle
|
// CPU Busy history: 100 - idle
|
||||||
if (metric === 'cpuBusy') {
|
if (metric === 'cpuBusy') {
|
||||||
@@ -655,7 +794,8 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st
|
|||||||
netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
|
netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
|
||||||
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
|
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
|
||||||
sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
|
sockstatTcp: `node_sockstat_TCP_inuse{instance="${node}",job="${job}"}`,
|
||||||
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`
|
sockstatTcpMem: `node_sockstat_TCP_mem{instance="${node}",job="${job}"} * 4096`,
|
||||||
|
conntrackUsedPct: `(node_nf_conntrack_entries{instance="${node}",job="${job}"} / node_nf_conntrack_entries_limit{instance="${node}",job="${job}"}) * 100`
|
||||||
};
|
};
|
||||||
|
|
||||||
const rangeObj = parseRange(range, start, end);
|
const rangeObj = parseRange(range, start, end);
|
||||||
@@ -789,10 +929,8 @@ module.exports = {
|
|||||||
getLatency: async (blackboxUrl, target) => {
|
getLatency: async (blackboxUrl, target) => {
|
||||||
if (!blackboxUrl || !target) return null;
|
if (!blackboxUrl || !target) return null;
|
||||||
try {
|
try {
|
||||||
const normalized = blackboxUrl.trim().replace(/\/+$/, '');
|
const normalized = normalizeUrl(blackboxUrl);
|
||||||
|
|
||||||
// Construct a single optimized query searching for priority metrics and common labels
|
|
||||||
// Prioritize probe_icmp_duration_seconds OVER probe_duration_seconds
|
|
||||||
const queryExpr = `(
|
const queryExpr = `(
|
||||||
probe_icmp_duration_seconds{phase="rtt", instance="${target}"} or
|
probe_icmp_duration_seconds{phase="rtt", instance="${target}"} or
|
||||||
probe_icmp_duration_seconds{phase="rtt", target="${target}"} or
|
probe_icmp_duration_seconds{phase="rtt", target="${target}"} or
|
||||||
@@ -804,14 +942,9 @@ module.exports = {
|
|||||||
probe_duration_seconds{target="${target}"}
|
probe_duration_seconds{target="${target}"}
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
const params = new URLSearchParams({ query: queryExpr });
|
const result = await query(normalized, queryExpr);
|
||||||
const res = await fetch(`${normalized}/api/v1/query?${params.toString()}`);
|
if (result && result.length > 0) {
|
||||||
|
return parseFloat(result[0].value[1]) * 1000;
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.status === 'success' && data.data.result.length > 0) {
|
|
||||||
return parseFloat(data.data.result[0].value[1]) * 1000;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user