Files
PromdataPanel/public/js/app.js
2026-04-11 00:20:44 +08:00

2800 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Main Application - Data Visualization Display Wall
*/
(function () {
'use strict';
// ---- Config ----
const REFRESH_INTERVAL = 5000; // 5 seconds
const NETWORK_HISTORY_INTERVAL = 60000; // 1 minute
// ---- DOM Elements ----
const dom = {
clock: document.getElementById('clock'), // May be null if removed from UI
totalServersLabel: document.getElementById('totalServersLabel'),
totalServers: document.getElementById('totalServers'),
cpuPercent: document.getElementById('cpuPercent'),
cpuDetail: document.getElementById('cpuDetail'),
memPercent: document.getElementById('memPercent'),
memDetail: document.getElementById('memDetail'),
diskPercent: document.getElementById('diskPercent'),
diskDetail: document.getElementById('diskDetail'),
totalBandwidthTx: document.getElementById('totalBandwidthTx'),
totalBandwidthRx: document.getElementById('totalBandwidthRx'),
traffic24hRx: document.getElementById('traffic24hRx'),
serverSearchFilter: document.getElementById('serverSearchFilter'),
traffic24hTx: document.getElementById('traffic24hTx'),
traffic24hTotal: document.getElementById('traffic24hTotal'),
trafficP95: document.getElementById('trafficP95'),
networkCanvas: document.getElementById('networkCanvas'),
serverTableBody: document.getElementById('serverTableBody'),
btnSettings: document.getElementById('btnSettings'),
settingsModal: document.getElementById('settingsModal'),
modalClose: document.getElementById('modalClose'),
sourceName: document.getElementById('sourceName'),
sourceUrl: document.getElementById('sourceUrl'),
sourceType: document.getElementById('sourceType'),
sourceDesc: document.getElementById('sourceDesc'),
btnTest: document.getElementById('btnTest'),
btnAdd: document.getElementById('btnAdd'),
isServerSource: document.getElementById('isServerSource'),
formMessage: document.getElementById('formMessage'),
sourceItems: document.getElementById('sourceItems'),
serverSourceOption: document.getElementById('serverSourceOption'),
faviconUrlInput: document.getElementById('faviconUrlInput'),
logoUrlDarkInput: document.getElementById('logoUrlDarkInput'),
showPageNameInput: document.getElementById('showPageNameInput'),
requireLoginForServerDetailsInput: document.getElementById('requireLoginForServerDetailsInput'),
// Site Settings
modalTabs: document.querySelectorAll('.modal-tab'),
tabContents: document.querySelectorAll('.tab-content'),
pageNameInput: document.getElementById('pageNameInput'),
siteTitleInput: document.getElementById('siteTitleInput'),
logoUrlInput: document.getElementById('logoUrlInput'),
btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'),
btnSaveSecuritySettings: document.getElementById('btnSaveSecuritySettings'),
siteSettingsMessage: document.getElementById('siteSettingsMessage'),
logoText: document.getElementById('logoText'),
logoIconContainer: document.getElementById('logoIconContainer'),
defaultThemeInput: document.getElementById('defaultThemeInput'),
show95BandwidthInput: document.getElementById('show95BandwidthInput'),
siteFavicon: document.getElementById('siteFavicon'),
// Auth & Theme elements
themeToggle: document.getElementById('themeToggle'),
sunIcon: document.querySelector('.sun-icon'),
moonIcon: document.querySelector('.moon-icon'),
userSection: document.getElementById('userSection'),
btnLogin: document.getElementById('btnLogin'),
loginModal: document.getElementById('loginModalOverlay'),
closeLoginModal: document.getElementById('closeLoginModal'),
loginForm: document.getElementById('loginForm'),
loginError: document.getElementById('loginError'),
footerTime: document.getElementById('footerTime'),
legendP95: document.getElementById('legendP95'),
legendRx: document.getElementById('legendRx'),
legendTx: document.getElementById('legendTx'),
p95LabelText: document.getElementById('p95LabelText'),
p95TypeSelect: document.getElementById('p95TypeSelect'),
routeSourceSelect: document.getElementById('routeSourceSelect'),
routeSourceInput: document.getElementById('routeSourceInput'),
routeDestInput: document.getElementById('routeDestInput'),
routeTargetInput: document.getElementById('routeTargetInput'),
btnAddRoute: document.getElementById('btnAddRoute'),
latencyRoutesList: document.getElementById('latencyRoutesList'),
btnCancelEditRoute: document.getElementById('btnCancelEditRoute'),
detailDiskTotal: document.getElementById('detailDiskTotal'),
// Server Details Modal
serverDetailModal: document.getElementById('serverDetailModal'),
serverDetailClose: document.getElementById('serverDetailClose'),
serverDetailTitle: document.getElementById('serverDetailTitle'),
detailMetricsList: document.getElementById('detailMetricsList'),
detailLoading: document.getElementById('detailLoading'),
detailCpuCores: document.getElementById('detailCpuCores'),
detailMemTotal: document.getElementById('detailMemTotal'),
detailUptime: document.getElementById('detailUptime'),
detailContainer: document.getElementById('detailContainer'),
pageSizeSelect: document.getElementById('pageSizeSelect'),
paginationControls: document.getElementById('paginationControls'),
// Auth security
oldPasswordInput: document.getElementById('oldPassword'),
newPasswordInput: document.getElementById('newPassword'),
confirmNewPasswordInput: document.getElementById('confirmNewPassword'),
networkSourceSelector: document.getElementById('network-source-selector'),
btnChangePassword: document.getElementById('btnChangePassword'),
changePasswordMessage: document.getElementById('changePasswordMessage'),
globeContainer: document.getElementById('globeContainer'),
globeTotalNodes: document.getElementById('globeTotalNodes'),
globeTotalRegions: document.getElementById('globeTotalRegions'),
sourceFilter: document.getElementById('sourceFilter'),
btnResetSort: document.getElementById('btnResetSort'),
detailPartitionsContainer: document.getElementById('detailPartitionsContainer'),
detailPartitionsList: document.getElementById('detailPartitionsList'),
partitionSummary: document.getElementById('partitionSummary'),
partitionHeader: document.getElementById('partitionHeader'),
globeCard: document.getElementById('globeCard'),
btnExpandGlobe: document.getElementById('btnExpandGlobe'),
btnRefreshNetwork: document.getElementById('btnRefreshNetwork'),
showServerIpInput: document.getElementById('showServerIpInput'),
ipMetricNameInput: document.getElementById('ipMetricNameInput'),
ipLabelNameInput: document.getElementById('ipLabelNameInput'),
// Footer & Filing
icpFilingInput: document.getElementById('icpFilingInput'),
psFilingInput: document.getElementById('psFilingInput'),
icpFilingDisplay: document.getElementById('icpFilingDisplay'),
ps_filingDisplay: document.getElementById('psFilingDisplay'),
ps_filingText: document.getElementById('psFilingText'),
copyrightYear: document.getElementById('copyrightYear'),
customMetricsList: document.getElementById('customMetricsList'),
btnAddCustomMetric: document.getElementById('btnAddCustomMetric'),
btnSaveCustomMetrics: document.getElementById('btnSaveCustomMetrics'),
customDataContainer: document.getElementById('customDataContainer')
};
// ---- State ----
let previousMetrics = null;
let networkChart = null;
let ws = null; // WebSocket instance
let isWsConnecting = false; // Connection lock
let user = null; // Currently logged in user
let currentServerDetail = { instance: null, job: null, source: null, charts: {} };
let allServersData = [];
let currentSourceFilter = 'all';
let currentPage = 1;
let pageSize = 20;
let currentLatencies = []; // Array of {id, source, dest, latency}
let latencyTimer = null;
let mapResizeHandler = null; // For map cleanup
let siteThemeQuery = null; // For media query cleanup
let siteThemeHandler = null;
let backgroundIntervals = []; // To track setIntervals
let lastMapDataHash = ''; // Cache for map rendering optimization
// Load sort state from localStorage or use default
let currentSort = { column: 'up', direction: 'desc' };
try {
const savedSort = localStorage.getItem('serverListSort');
if (savedSort) {
currentSort = JSON.parse(savedSort);
}
} catch (e) {
console.warn('Failed to load sort state', e);
}
let myMap2D = null;
let editingRouteId = null;
async function fetchJsonWithFallback(urls) {
let lastError = null;
for (const url of urls) {
try {
const response = await fetch(url, { cache: 'force-cache' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('All JSON sources failed');
}
// ---- Initialize ----
function init() {
// Resource Gauges Time
updateGaugesTime();
setInterval(updateGaugesTime, 1000);
// Initial footer year
if (dom.copyrightYear) {
dom.copyrightYear.textContent = new Date().getFullYear();
}
// Initial theme check (localStorage handled after site settings load to ensure priority)
// Network chart
networkChart = new AreaChart(dom.networkCanvas);
// ---- Custom Metrics Helpers ----
function addMetricRow(config = {}) {
const row = document.createElement('div');
row.className = 'metric-row';
row.style = 'background: rgba(255,255,255,0.03); padding: 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border-color);';
row.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 10px; margin-bottom: 8px;">
<input type="text" placeholder="显示名 (如:内核)" class="metric-display-name" value="${config.name || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
<input type="text" placeholder="指标名 (PromQL)" class="metric-query" value="${config.metric || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
<input type="text" placeholder="取值标签" class="metric-label" value="${config.label || ''}" style="padding: 6px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); font-size: 0.85rem;">
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<label style="font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" class="metric-is-ip" ${config.is_ip ? 'checked' : ''} style="margin-right: 5px;"> 设为 IP 发现源
</label>
<button class="btn-remove-metric" style="background: none; border: none; color: #ff4d4f; cursor: pointer; font-size: 0.75rem;">
<i class="fas fa-trash"></i> 删除
</button>
</div>
`;
row.querySelector('.btn-remove-metric').onclick = () => row.remove();
dom.customMetricsList.appendChild(row);
}
function loadCustomMetricsUI(metrics) {
if (!dom.customMetricsList) return;
dom.customMetricsList.innerHTML = '';
let list = [];
try {
list = typeof metrics === 'string' ? JSON.parse(metrics) : (metrics || []);
} catch(e) {}
if (Array.isArray(list)) {
list.forEach(m => addMetricRow(m));
}
if (list.length === 0) {
// Add a placeholder/default row if empty
}
}
function getCustomMetricsFromUI() {
const rows = dom.customMetricsList.querySelectorAll('.metric-row');
const metrics = [];
rows.forEach(row => {
const name = row.querySelector('.metric-display-name').value.trim();
const metric = row.querySelector('.metric-query').value.trim();
const label = row.querySelector('.metric-label').value.trim();
const is_ip = row.querySelector('.metric-is-ip').checked;
if (metric) {
metrics.push({ name, metric, label, is_ip });
}
});
return metrics;
}
// Bind Events
if (dom.btnAddCustomMetric) dom.btnAddCustomMetric.onclick = () => addMetricRow();
if (dom.btnSaveCustomMetrics) {
dom.btnSaveCustomMetrics.onclick = saveSiteSettings;
}
// Initial map
initMap2D();
// Event listeners
dom.btnSettings.addEventListener('click', openSettings);
dom.modalClose.addEventListener('click', closeSettings);
// Toggle server source option based on type
if (dom.sourceType) {
dom.sourceType.addEventListener('change', () => {
if (dom.sourceType.value === 'blackbox') {
dom.serverSourceOption.style.display = 'none';
dom.isServerSource.checked = false;
} else {
dom.serverSourceOption.style.display = 'flex';
dom.isServerSource.checked = true;
}
});
}
if (dom.btnCancelEditRoute) {
dom.btnCancelEditRoute.onclick = cancelEditRoute;
}
// Auth & Theme listeners
if (dom.themeToggle) dom.themeToggle.addEventListener('change', toggleTheme);
if (dom.btnSettings) dom.btnSettings.addEventListener('click', openSettings);
if (dom.modalClose) dom.modalClose.addEventListener('click', closeSettings);
if (dom.btnTest) dom.btnTest.addEventListener('click', testConnection);
if (dom.btnAdd) dom.btnAdd.addEventListener('click', addSource);
if (dom.btnRefreshNetwork) dom.btnRefreshNetwork.addEventListener('click', fetchNetworkHistory);
// Auth & Login
if (dom.btnLogin) dom.btnLogin.addEventListener('click', openLoginModal);
if (dom.closeLoginModal) dom.closeLoginModal.addEventListener('click', closeLoginModal);
if (dom.loginForm) dom.loginForm.addEventListener('submit', handleLogin);
if (dom.loginModal) {
dom.loginModal.addEventListener('click', (e) => {
if (e.target === dom.loginModal) closeLoginModal();
});
}
// Tab switching
if (dom.modalTabs) {
dom.modalTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab');
switchTab(targetTab);
});
});
}
// Site settings
if (dom.btnSaveSiteSettings) {
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
}
if (dom.btnSaveCustomMetrics) {
dom.btnSaveCustomMetrics.onclick = saveSiteSettings;
}
if (dom.btnAddRoute) {
dom.btnAddRoute.addEventListener('click', addLatencyRoute);
}
// Auth password change
if (dom.btnChangePassword) {
dom.btnChangePassword.addEventListener('click', saveChangePassword);
}
// Globe expansion (FLIP animation via Web Animations API)
let savedGlobeRect = null;
let globeAnimating = false;
function expandGlobe() {
if (dom.globeCard.classList.contains('expanded') || globeAnimating) return;
globeAnimating = true;
// FLIP: capture original position
savedGlobeRect = dom.globeCard.getBoundingClientRect();
// Apply expanded state
dom.globeCard.classList.add('expanded');
dom.btnExpandGlobe.classList.add('active');
dom.btnExpandGlobe.title = '缩小显示';
dom.btnExpandGlobe.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M10 14l-7 7" />
</svg>
`;
// Resize ECharts immediately (prevents flash)
if (myMap2D) myMap2D.resize();
// FLIP: capture expanded position
const endRect = dom.globeCard.getBoundingClientRect();
const scaleX = savedGlobeRect.width / endRect.width;
const scaleY = savedGlobeRect.height / endRect.height;
const dx = savedGlobeRect.left - endRect.left;
const dy = savedGlobeRect.top - endRect.top;
// Animate using Web Animations API (bypasses all CSS conflicts)
const anim = dom.globeCard.animate([
{
transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`,
boxShadow: '0 0 0 0 transparent, 0 0 0 0 transparent',
offset: 0
},
{
transform: 'translate(0, 0) scale(1)',
boxShadow: '0 0 80px rgba(0,0,0,0.8), 0 0 0 100vh rgba(0,0,0,0.65)',
offset: 1
}
], {
duration: 600,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'none'
});
anim.onfinish = () => {
globeAnimating = false;
};
}
function collapseGlobe() {
if (!dom.globeCard.classList.contains('expanded') || globeAnimating) return;
globeAnimating = true;
const resetState = () => {
dom.globeCard.classList.remove('expanded', 'globe-collapsing');
dom.btnExpandGlobe.classList.remove('active');
dom.btnExpandGlobe.title = '放大显示';
dom.btnExpandGlobe.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
`;
globeAnimating = false;
if (myMap2D) requestAnimationFrame(() => myMap2D.resize());
};
if (!savedGlobeRect) {
resetState();
return;
}
dom.globeCard.classList.add('globe-collapsing');
// FLIP: compute target transform to original position
const expandedRect = dom.globeCard.getBoundingClientRect();
const scaleX = savedGlobeRect.width / expandedRect.width;
const scaleY = savedGlobeRect.height / expandedRect.height;
const dx = savedGlobeRect.left - expandedRect.left;
const dy = savedGlobeRect.top - expandedRect.top;
// Animate from current expanded state to original rect
const anim = dom.globeCard.animate([
{
transform: 'translate(0, 0) scale(1)',
boxShadow: '0 0 80px rgba(0,0,0,0.8), 0 0 0 100vh rgba(0,0,0,0.65)',
offset: 0
},
{
transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`,
boxShadow: '0 0 0 0 transparent, 0 0 0 0 transparent',
offset: 1
}
], {
duration: 500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' // Hold final frame until we remove class
});
anim.onfinish = () => {
anim.cancel(); // Release the fill-forwards hold
resetState();
};
}
if (dom.btnExpandGlobe) {
dom.btnExpandGlobe.addEventListener('click', () => {
if (dom.globeCard.classList.contains('expanded')) {
collapseGlobe();
} else {
expandGlobe();
}
});
}
if (dom.btnRefreshNetwork) {
dom.btnRefreshNetwork.addEventListener('click', async () => {
const icon = dom.btnRefreshNetwork.querySelector('svg');
if (icon) icon.style.animation = 'spin 0.8s ease-in-out';
// Force refresh all Prometheus 24h data and overview
await Promise.all([
fetchNetworkHistory(true),
fetchMetrics(true)
]);
if (icon) {
setTimeout(() => {
icon.style.animation = '';
}, 800);
}
});
}
// Keyboard shortcut
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettings();
closeLoginModal();
closeServerDetail();
collapseGlobe();
}
});
// Server detail modal listeners
dom.serverDetailClose.addEventListener('click', closeServerDetail);
dom.serverDetailModal.addEventListener('click', (e) => {
if (e.target === dom.serverDetailModal) closeServerDetail();
});
// Server table row click delegator
dom.serverTableBody.addEventListener('click', (e) => {
// Don't trigger detail if clicking a button or something interactive inside (none currently)
const row = e.target.closest('tr');
if (row && !row.classList.contains('empty-row')) {
const instance = row.getAttribute('data-instance');
const job = row.getAttribute('data-job');
const source = row.getAttribute('data-source');
if (instance && job && source) {
if (requiresLoginForServerDetails() && !user) {
promptLogin('登录后可查看服务器详细指标');
return;
}
openServerDetail(instance, job, source);
}
}
});
// Server table header sorting
const tableHeader = document.querySelector('.server-table thead');
if (tableHeader) {
// Sync UI with initial state
const initialHeaders = tableHeader.querySelectorAll('th.sortable');
initialHeaders.forEach(th => {
const col = th.getAttribute('data-sort');
if (col === currentSort.column) {
th.classList.add('active');
th.setAttribute('data-dir', currentSort.direction);
} else {
th.classList.remove('active');
th.removeAttribute('data-dir');
}
});
tableHeader.addEventListener('click', (e) => {
const th = e.target.closest('th.sortable');
if (th) {
const column = th.getAttribute('data-sort');
handleHeaderSort(column);
}
});
}
// P95 Toggle
if (dom.legendP95) {
dom.legendP95.addEventListener('click', () => {
networkChart.showP95 = !networkChart.showP95;
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
networkChart.draw();
});
}
// RX/TX Legend Toggle
if (dom.legendRx) {
dom.legendRx.addEventListener('click', () => {
networkChart.showRx = !networkChart.showRx;
dom.legendRx.classList.toggle('disabled', !networkChart.showRx);
networkChart.draw();
});
}
if (dom.legendTx) {
dom.legendTx.addEventListener('click', () => {
networkChart.showTx = !networkChart.showTx;
dom.legendTx.classList.toggle('disabled', !networkChart.showTx);
networkChart.draw();
});
}
// Source filter listener
if (dom.sourceFilter) {
dom.sourceFilter.addEventListener('change', () => {
currentSourceFilter = dom.sourceFilter.value;
currentPage = 1; // Reset page on filter change
renderFilteredServers();
});
}
// Page size listener
if (dom.pageSizeSelect) {
dom.pageSizeSelect.addEventListener('change', () => {
pageSize = parseInt(dom.pageSizeSelect.value, 10);
currentPage = 1; // Reset page on size change
renderFilteredServers();
});
}
// Reset sort listener
if (dom.btnResetSort) {
dom.btnResetSort.addEventListener('click', resetSort);
}
// Server list search
if (dom.serverSearchFilter) {
dom.serverSearchFilter.value = ''; // Ensure it's clear on load
dom.serverSearchFilter.addEventListener('input', () => {
currentPage = 1; // Reset page on search
renderFilteredServers();
});
}
// Check auth status
checkAuthStatus();
// Start data fetching
fetchMetrics();
fetchNetworkHistory();
fetchLatency();
// Site settings
if (window.SITE_SETTINGS) {
applySiteSettings(window.SITE_SETTINGS);
// Actual theme class already applied in head, just update icons and inputs
const savedTheme = localStorage.getItem('theme');
const currentTheme = savedTheme || window.SITE_SETTINGS.default_theme || 'dark';
updateThemeIcons(currentTheme);
// Still populate inputs
dom.pageNameInput.value = window.SITE_SETTINGS.page_name || '';
if (dom.siteTitleInput) dom.siteTitleInput.value = window.SITE_SETTINGS.title || '';
if (dom.logoUrlInput) dom.logoUrlInput.value = window.SITE_SETTINGS.logo_url || '';
if (dom.defaultThemeInput) dom.defaultThemeInput.value = window.SITE_SETTINGS.default_theme || 'dark';
if (dom.show95BandwidthInput) dom.show95BandwidthInput.value = window.SITE_SETTINGS.show_95_bandwidth ? "1" : "0";
if (dom.p95TypeSelect) dom.p95TypeSelect.value = window.SITE_SETTINGS.p95_type || 'tx';
if (dom.requireLoginForServerDetailsInput) dom.requireLoginForServerDetailsInput.value = window.SITE_SETTINGS.require_login_for_server_details ? "1" : "0";
if (dom.icpFilingInput) dom.icpFilingInput.value = window.SITE_SETTINGS.icp_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.faviconUrlInput) dom.faviconUrlInput.value = window.SITE_SETTINGS.favicon_url || '';
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();
// Bind save button for security tab
if (dom.btnSaveSecuritySettings) {
dom.btnSaveSecuritySettings.addEventListener('click', saveSiteSettings);
}
// Security dependency listener
if (dom.requireLoginForServerDetailsInput) {
dom.requireLoginForServerDetailsInput.addEventListener('change', updateSecurityDependency);
}
// Track intervals for resource management
initWebSocket();
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
}
// ---- Real-time WebSocket ----
function initWebSocket() {
if (isWsConnecting) return;
isWsConnecting = true;
if (ws) {
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
isWsConnecting = false;
console.log('WS connection established');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'overview') {
allServersData = msg.data.servers || [];
if (msg.data.latencies) {
currentLatencies = msg.data.latencies;
}
updateDashboard(msg.data);
}
} catch (err) {
console.error('WS Message Error:', err);
}
};
ws.onclose = () => {
isWsConnecting = false;
console.log('WS connection closed. Reconnecting in 5s...');
setTimeout(initWebSocket, 5000);
};
ws.onerror = (err) => {
isWsConnecting = false;
ws.close();
};
}
// ---- Theme Switching ----
function toggleTheme() {
const theme = dom.themeToggle.checked ? 'light' : 'dark';
document.documentElement.classList.toggle('light-theme', theme === 'light');
localStorage.setItem('theme', theme);
updateThemeIcons(theme);
updateMap2DTheme(theme);
// After theme toggle, re-apply site settings to handle potential logo change
if (window.SITE_SETTINGS) {
applySiteSettings(window.SITE_SETTINGS);
}
}
function applyTheme(theme) {
let actualTheme = theme;
if (theme === 'auto') {
actualTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
const isLight = actualTheme === 'light';
dom.themeToggle.checked = isLight;
document.documentElement.classList.toggle('light-theme', isLight);
updateThemeIcons(actualTheme);
updateMap2DTheme(actualTheme);
// After theme change, re-apply site settings to handle potential logo change
if (window.SITE_SETTINGS) {
applySiteSettings(window.SITE_SETTINGS);
}
}
function updateFavicon(url) {
const safeUrl = sanitizeAssetUrl(url);
if (!safeUrl) return;
const link = dom.siteFavicon || document.querySelector("link[rel*='icon']");
if (link) {
link.href = safeUrl;
}
}
function updateThemeIcons(theme) {
// Icons are in the slider, we adjust their opacity to show which one is active
if (theme === 'light') {
dom.sunIcon.style.opacity = '0.3';
dom.moonIcon.style.opacity = '1';
} else {
dom.sunIcon.style.opacity = '1';
dom.moonIcon.style.opacity = '0.3';
}
}
// ---- Auth Logic ----
async function checkAuthStatus() {
try {
const resp = await fetch('/api/auth/status');
const data = await resp.json();
if (data.authenticated) {
updateUserUI(data.username);
} else {
updateUserUI(null);
}
} catch (err) {
updateUserUI(null);
}
}
function updateUserUI(username) {
if (username) {
user = username;
dom.btnSettings.style.display = 'flex';
dom.userSection.innerHTML = `
<div class="user-info">
<span class="username">${escapeHtml(username)}</span>
<button class="btn btn-logout" id="btnLogout">退出</button>
</div>
`;
document.getElementById('btnLogout').addEventListener('click', handleLogout);
} else {
user = null;
dom.btnSettings.style.display = 'none';
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
document.getElementById('btnLogin').addEventListener('click', openLoginModal);
}
}
function promptLogin(message = '该操作需要登录') {
openLoginModal();
if (message) {
dom.loginError.textContent = message;
dom.loginError.style.display = 'block';
}
}
function sanitizeAssetUrl(url) {
if (!url || typeof url !== 'string') return null;
const trimmed = url.trim();
if (!trimmed) return null;
if (/^(https?:|data:image\/|\/)/i.test(trimmed)) return trimmed;
return null;
}
function requiresLoginForServerDetails() {
if (!window.SITE_SETTINGS) return true;
return !!window.SITE_SETTINGS.require_login_for_server_details;
}
function renderLogoImage(url) {
if (!dom.logoIconContainer) return;
const safeUrl = sanitizeAssetUrl(url);
if (safeUrl) {
const img = document.createElement('img');
img.src = safeUrl;
img.alt = 'Logo';
img.className = 'logo-icon-img';
dom.logoIconContainer.replaceChildren(img);
return;
}
dom.logoIconContainer.innerHTML = `
<svg class="logo-icon" id="logoSvg" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="28" height="28" rx="8" stroke="url(#logoGrad)" stroke-width="2.5"/>
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="12" cy="14" r="2" fill="url(#logoGrad)"/>
<circle cx="20" cy="10" r="2" fill="url(#logoGrad)"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
</svg>
`;
}
function openLoginModal() {
dom.loginModal.classList.add('active');
dom.loginError.style.display = 'none';
}
function closeLoginModal() {
dom.loginModal.classList.remove('active');
dom.loginForm.reset();
}
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await resp.json();
if (resp.ok) {
updateUserUI(data.username);
closeLoginModal();
// Refresh data sources list for the filter dropdown
loadSourceCount();
// Refresh site settings (logo, filings, theme, etc.)
loadSiteSettings();
// Refresh dashboard data
fetchMetrics(true);
fetchNetworkHistory(true);
} else {
dom.loginError.textContent = data.error || '登录失败';
dom.loginError.style.display = 'block';
}
} catch (err) {
dom.loginError.textContent = '服务器错误';
dom.loginError.style.display = 'block';
}
}
async function handleLogout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
updateUserUI(null);
} catch (err) {
console.error('Logout failed:', err);
}
}
// ---- Clock ----
function updateClock() {
if (dom.clock) {
dom.clock.textContent = formatClock();
}
}
function updateGaugesTime() {
const clockStr = formatClock();
if (dom.footerTime) {
dom.footerTime.textContent = clockStr;
}
}
// ---- Fetch Metrics ----
async function fetchMetrics(force = false) {
try {
const url = `/api/metrics/overview${force ? '?force=true' : ''}`;
const response = await fetch(url);
const data = await response.json();
allServersData = data.servers || [];
updateDashboard(data);
} catch (err) {
console.error('Error fetching metrics:', err);
}
}
async function fetchLatency() {
try {
const response = await fetch('/api/metrics/latency');
const data = await response.json();
currentLatencies = data.routes || [];
if (allServersData.length > 0) {
updateMap2D(allServersData);
}
} catch (err) {
console.error('Error fetching latency:', err);
}
}
// ---- Global 2D Map ----
async function initMap2D() {
if (!dom.globeContainer) return;
if (typeof echarts === 'undefined') {
console.warn('[Map2D] ECharts library not loaded.');
dom.globeContainer.innerHTML = `<div class="chart-empty">地图库加载失败</div>`;
return;
}
try {
// Fetch map data with CDN fallback so restricted networks degrade more gracefully.
const worldJSON = await fetchJsonWithFallback([
'/vendor/world.json'
]);
// Transform to Pacific-centered correctly
const transformCoords = (coords) => {
if (!Array.isArray(coords) || coords.length === 0) return;
const first = coords[0];
if (!Array.isArray(first)) return;
if (typeof first[0] === 'number') { // Ring
let sum = 0;
coords.forEach(pt => sum += pt[0]);
let avg = sum / coords.length;
if (avg < -20) {
coords.forEach(pt => pt[0] += 360);
}
} else {
coords.forEach(transformCoords);
}
};
if (worldJSON && worldJSON.features) {
worldJSON.features.forEach(feature => {
if (feature.geometry && feature.geometry.coordinates) {
transformCoords(feature.geometry.coordinates);
}
});
}
echarts.registerMap('world', worldJSON);
if (myMap2D) {
myMap2D.dispose();
if (mapResizeHandler) {
window.removeEventListener('resize', mapResizeHandler);
}
}
myMap2D = echarts.init(dom.globeContainer);
const isLight = document.documentElement.classList.contains('light-theme');
// Helper to transform app coordinates to shifted map coordinates
const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng);
const option = {
backgroundColor: 'transparent',
tooltip: {
show: true,
trigger: 'item',
confine: true,
transitionDuration: 0,
backgroundColor: 'rgba(10, 14, 26, 0.9)',
borderColor: 'var(--accent-indigo)',
textStyle: { color: '#fff', fontSize: 12 },
formatter: (params) => {
const d = params.data;
if (!d) return '';
return `
<div style="padding: 4px;">
<div style="font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 4px; padding-bottom: 4px;">${escapeHtml(d.job)}</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);">${escapeHtml(d.city || '')}, ${escapeHtml(d.countryName || d.country || '')}</div>
<div style="font-size: 0.75rem; margin-top: 4px;">
<span style="color: var(--accent-cyan);">BW:</span> ↓${formatBandwidth(d.netRx)}${formatBandwidth(d.netTx)}
</div>
</div>
`;
}
},
geo: {
map: 'world',
roam: true,
center: [165, 20], // Centered in Pacific
zoom: 1.1,
aspectScale: 0.85,
emphasis: {
label: { show: false },
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
},
itemStyle: {
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
borderColor: isLight ? '#cbd5e1' : '#2d334d',
borderWidth: 1
},
select: {
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
}
},
series: [{
type: 'scatter',
coordinateSystem: 'geo',
geoIndex: 0,
symbolSize: 5,
itemStyle: {
color: '#06b6d4'
},
data: []
}]
};
myMap2D.setOption(option);
mapResizeHandler = debounce(() => {
if (myMap2D) myMap2D.resize();
}, 100);
window.addEventListener('resize', mapResizeHandler);
if (allServersData.length > 0) {
updateMap2D(allServersData);
}
} catch (err) {
console.error('[Map2D] Initialization failed:', err);
dom.globeContainer.innerHTML = '<div class="chart-empty">Map data unavailable</div>';
myMap2D = null;
}
}
function updateMap2DTheme(theme) {
if (!myMap2D) return;
const isLight = theme === 'light';
myMap2D.setOption({
geo: {
itemStyle: {
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
borderColor: isLight ? '#cbd5e1' : '#2d334d'
},
emphasis: {
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
}
}
});
}
// Debounced version of the map update to avoid rapid succession calls
// Optimized map update with change detection and performance tuning
const updateMap2D = debounce(function (servers) {
if (!myMap2D || !servers) return;
// Shift longitude for Pacific-centered view
const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng);
// Create a data fingerprint to avoid redundant renders
// We only care about geographical positions and latency connectivity for the map
const dataFingerprint = JSON.stringify({
serverCount: servers.length,
latencyCount: currentLatencies.length,
// Sample critical connectivity data (first/last items)
lHash: currentLatencies.length > 0 ? (currentLatencies[0].id + currentLatencies[currentLatencies.length-1].id) : '',
// Include theme to ensure theme toggle still triggers redraw
theme: document.documentElement.classList.contains('light-theme') ? 'light' : 'dark'
});
if (dataFingerprint === lastMapDataHash) {
return;
}
lastMapDataHash = dataFingerprint;
// 1. Prepare geoData for scatter points
const geoData = servers
.filter(s => s.lat && s.lng)
.map(s => ({
name: s.job,
value: [shiftLng(s.lng), s.lat],
job: s.job,
city: s.city,
country: s.country,
countryName: s.countryName,
netRx: s.netRx,
netTx: s.netTx
}));
// Start with the scatter series
const finalSeries = [
{
id: 'nodes-scatter',
type: 'scatter',
coordinateSystem: 'geo',
geoIndex: 0,
symbolSize: 6,
itemStyle: {
color: '#06b6d4'
},
data: geoData,
zlevel: 1,
animation: false // Performance optimization
}
];
// 3. Process latency routes with grouping
if (currentLatencies && currentLatencies.length > 0) {
const countryCoords = {
'china': [116.4074, 39.9042],
'cn': [116.4074, 39.9042],
'beijing': [116.4074, 39.9042],
'shanghai': [121.4737, 31.2304],
'hong kong': [114.1694, 22.3193],
'hk': [114.1694, 22.3193],
'taiwan': [120.9605, 23.6978],
'tw': [120.9605, 23.6978],
'united states': [-95.7129, 37.0902],
'us': [-95.7129, 37.0902],
'us seattle': [-122.3321, 47.6062],
'seattle': [-122.3321, 47.6062],
'us chicago': [-87.6298, 41.8781],
'chicago': [-87.6298, 41.8781],
'us houston': [-95.3698, 29.7604],
'houston': [-95.3698, 29.7604],
'new york': [-74.0060, 40.7128],
'new york corp': [-74.0060, 40.7128],
'san francisco': [-122.4194, 37.7749],
'los angeles': [-118.2437, 34.0522],
'japan': [138.2529, 36.2048],
'jp': [138.2529, 36.2048],
'tokyo': [139.6917, 35.6895],
'singapore': [103.8198, 1.3521],
'sg': [103.8198, 1.3521],
'germany': [8.6821, 50.1109],
'de': [8.6821, 50.1109],
'frankfurt': [8.6821, 50.1109],
'united kingdom': [-3.436, 55.3781],
'uk': [-3.436, 55.3781],
'london': [-0.1276, 51.5074],
'france': [2.2137, 46.2276],
'fr': [2.2137, 46.2276],
'paris': [2.3522, 48.8566],
'south korea': [127.7669, 35.9078],
'korea': [127.7669, 35.9078],
'kr': [127.7669, 35.9078],
'seoul': [126.9780, 37.5665]
};
const getShiftedCoords = (name) => {
const lower = (name || '').toLowerCase().trim();
// 优先从当前服务器列表中查找坐标,解决线不从服务器起始的问题
const server = servers.find(s =>
(s.job && s.job.toLowerCase() === lower) ||
(s.instance && s.instance.toLowerCase() === lower)
);
if (server && server.lat && server.lng) {
return [shiftLng(server.lng), server.lat];
}
// 备选从静态预定义的国家/城市坐标库中查找
if (countryCoords[lower]) return [shiftLng(countryCoords[lower][0]), countryCoords[lower][1]];
return null;
};
const routeGroups = {};
currentLatencies.forEach(route => {
const start = getShiftedCoords(route.source);
const end = getShiftedCoords(route.dest);
if (start && end) {
const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const key = `${pts[0][0].toFixed(2)},${pts[0][1].toFixed(2)}_${pts[1][0].toFixed(2)},${pts[1][1].toFixed(2)}`;
if (!routeGroups[key]) routeGroups[key] = [];
routeGroups[key].push({ route, start, end });
}
});
const styleGroupedSeries = new Map();
Object.keys(routeGroups).forEach(key => {
const routesInGroup = routeGroups[key];
const count = routesInGroup.length;
routesInGroup.forEach((item, i) => {
const { route, start, end } = item;
const lowS = (route.source || '').toLowerCase().trim();
const lowD = (route.dest || '').toLowerCase().trim();
const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const isForward = (start === pts[0]);
const isSeattleJapan = ((lowS === 'seattle' || lowS === 'us seattle') && lowD === 'japan') ||
((lowD === 'seattle' || lowD === 'us seattle') && lowS === 'japan');
const names = [lowS, lowD].sort();
const routeHash = names.join('').split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
let finalCurve = 0.12;
if (isSeattleJapan) {
finalCurve = (lowS === 'seattle' || lowS === 'us seattle') ? -0.2 : 0.2;
} else if (count > 1) {
const magnitude = 0.12 + Math.floor(i / 2) * 0.12;
const spread = (i % 2 === 0) ? magnitude : -magnitude;
finalCurve = isForward ? spread : -spread;
}
const period = 3 + ((routeHash + i) % 5);
const styleKey = `${finalCurve.toFixed(2)}_${period}`;
// Performance Optimization: Limit maximum animated effects to 25 to prevent iGPU saturation
const effectVisible = styleGroupedSeries.size < 25;
if (!styleGroupedSeries.has(styleKey)) {
styleGroupedSeries.set(styleKey, {
id: 'latency-group-' + styleKey,
type: 'lines',
coordinateSystem: 'geo',
zlevel: 2,
effect: {
show: effectVisible,
period: period,
trailLength: 0.05, // Shorter trail = less GPU pixels to process
color: 'rgba(99, 102, 241, 0.6)',
symbol: 'circle', // Simpler symbol than arrow
symbolSize: 4 // Smaller symbol
},
lineStyle: {
color: 'rgba(99, 102, 241, 0.2)',
width: 1.5,
curveness: finalCurve
},
progressive: 50, // Progressive rendering for GPU offload
animation: false,
tooltip: {
formatter: (params) => {
const r = params.data.meta;
if (!r) return '';
const latVal = (r.latency !== null && r.latency !== undefined) ? `${r.latency.toFixed(2)} ms` : 'Measuring...';
return `
<div style="padding: 4px;">
<div style="font-weight: 700;">${r.source}${r.dest}</div>
<div style="font-size: 0.75rem; color: var(--accent-indigo); margin-top: 4px;">Latency: ${latVal}</div>
</div>
`;
}
},
data: []
});
}
styleGroupedSeries.get(styleKey).data.push({
fromName: route.source,
toName: route.dest,
coords: [start, end],
meta: route
});
});
});
styleGroupedSeries.forEach(s => finalSeries.push(s));
}
myMap2D.setOption({ series: finalSeries }, { replaceMerge: ['series'] });
if (dom.globeTotalNodes) dom.globeTotalNodes.textContent = geoData.length;
if (dom.globeTotalRegions) {
dom.globeTotalRegions.textContent = new Set(geoData.map(d => d.country)).size;
}
}, 800);
// ---- Update Dashboard ----
function updateDashboard(data) {
// Server count
dom.totalServers.textContent = `${data.activeServers}/${data.totalServers}`;
// CPU
const cpuPct = data.cpu.percent;
dom.cpuPercent.textContent = formatPercent(cpuPct);
dom.cpuDetail.textContent = `${data.cpu.used.toFixed(1)}/${data.cpu.total.toFixed(0)} 核心`;
// Memory
const memPct = data.memory.percent;
dom.memPercent.textContent = formatPercent(memPct);
dom.memDetail.textContent = `${formatBytes(data.memory.used)}/${formatBytes(data.memory.total)}`;
// Disk
const diskPct = data.disk.percent;
dom.diskPercent.textContent = formatPercent(diskPct);
dom.diskDetail.textContent = `${formatBytes(data.disk.used)}/${formatBytes(data.disk.total)}`;
// Bandwidth
dom.totalBandwidthTx.textContent = toMBps(data.network.tx || 0);
dom.totalBandwidthRx.textContent = toMBps(data.network.rx || 0);
// 24h traffic
dom.traffic24hRx.textContent = formatBytes(data.traffic24h.rx);
dom.traffic24hTx.textContent = formatBytes(data.traffic24h.tx);
dom.traffic24hTotal.textContent = formatBytes(data.traffic24h.total || (data.traffic24h.rx + data.traffic24h.tx));
// Update server table
renderFilteredServers();
// Update globe (latencies already updated in ws message handler)
updateMap2D(data.servers || []);
// Real-time update for server detail modal if open
if (dom.serverDetailModal.classList.contains('active') && currentServerDetail.instance) {
const currentS = (data.servers || []).find(s =>
s.instance === currentServerDetail.instance &&
s.job === currentServerDetail.job &&
s.source === currentServerDetail.source
);
if (currentS) {
updateServerDetailMetrics(currentS);
}
}
// Flash animation
if (previousMetrics) {
// No flash on update
}
previousMetrics = data;
}
// Real-time update for core server detail metrics (called from WebSocket broadcast)
function updateServerDetailMetrics(server) {
if (!server) return;
// Update the values displayed in the metric header cards within the detail modal
const metrics = [
{ key: 'cpuBusy', label: 'CPU 使用率', value: `
<div style="display: flex; align-items: baseline; gap: 8px;">
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(server.cpuPercent)}</span>
</div>` },
{ key: 'memUsedPct', label: '内存使用率 (RAM)', value: `
<div style="display: flex; align-items: baseline; gap: 8px;">
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(server.memPercent)}</span>
<span style="font-size: 0.7rem; color: var(--text-secondary); font-weight: normal;">(${formatBytes(server.memUsed)} / ${formatBytes(server.memTotal)})</span>
</div>` },
{ key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(server.diskPercent) },
{ key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(server.netRx) },
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(server.netTx) }
];
metrics.forEach(m => {
const el = document.getElementById(`metric-${m.key}`);
if (el) {
const valEl = el.querySelector('.metric-value');
if (valEl) valEl.innerHTML = m.value;
}
});
// Also update current active history charts if they are open
Object.keys(currentServerDetail.charts).forEach(key => {
const el = document.getElementById(`metric-${key}`);
if (el && el.classList.contains('active')) {
// If the chart is open, we don't automatically refresh the history (too heavy)
// But we could append the latest point if we wanted to.
// For now, updating the summary numbers is enough for "real-time".
}
});
}
function renderFilteredServers() {
let filtered = allServersData;
if (currentSourceFilter !== 'all') {
filtered = allServersData.filter(s => s.source === currentSourceFilter);
}
// Apply search filter
const searchTerm = (dom.serverSearchFilter?.value || '').toLowerCase().trim();
if (searchTerm) {
filtered = filtered.filter(s =>
(s.job || '').toLowerCase().includes(searchTerm) ||
(s.instance || '').toLowerCase().includes(searchTerm)
);
}
// Sort servers: online first, then by currentSort
filtered.sort((a, b) => {
// Primary sort: Always put online servers first unless sorting by 'up' explicitly
if (currentSort.column !== 'up') {
if (a.up !== b.up) return a.up ? -1 : 1;
} else {
// Specifically sorting by status: Online vs Offline
if (a.up !== b.up) {
const val = a.up ? -1 : 1;
return currentSort.direction === 'asc' ? -val : val;
}
}
// Secondary sort based on user choice
let valA, valB;
const col = currentSort.column;
const dir = currentSort.direction;
switch (col) {
case 'up':
// If we reached here, status is the same (both online or both offline)
// Fall back to job name sorting
valA = a.job || '';
valB = b.job || '';
return valA.localeCompare(valB);
case 'job':
valA = (a.job || '').toLowerCase();
valB = (b.job || '').toLowerCase();
return dir === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
case 'source':
valA = (a.source || '').toLowerCase();
valB = (b.source || '').toLowerCase();
return dir === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
case 'cpu':
valA = a.cpuPercent ?? 0;
valB = b.cpuPercent ?? 0;
break;
case 'mem':
valA = a.memPercent ?? 0;
valB = b.memPercent ?? 0;
break;
case 'disk':
valA = a.diskPercent ?? 0;
valB = b.diskPercent ?? 0;
break;
case 'netRx':
valA = a.netRx ?? 0;
valB = b.netRx ?? 0;
break;
case 'netTx':
valA = a.netTx ?? 0;
valB = b.netTx ?? 0;
break;
default:
valA = (a.job || '').toLowerCase();
valB = (b.job || '').toLowerCase();
return valA.localeCompare(valB);
}
// Numeric comparison
if (valA === valB) {
// If values are same, secondary fallback to job name for stable sort
return (a.job || '').localeCompare(b.job || '');
}
return dir === 'asc' ? valA - valB : valB - valA;
});
const totalFiltered = filtered.length;
const totalPages = Math.ceil(totalFiltered / pageSize) || 1;
if (currentPage > totalPages) currentPage = totalPages;
const startIndex = (currentPage - 1) * pageSize;
const paginated = filtered.slice(startIndex, startIndex + pageSize);
updateServerTable(paginated);
renderPagination(totalPages);
}
function renderPagination(totalPages) {
if (!dom.paginationControls) return;
if (totalPages <= 1) {
dom.paginationControls.innerHTML = '';
return;
}
let html = '';
// Previous button
html += `<button class="page-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="changePage(${currentPage - 1})">上页</button>`;
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`;
} else if (i === currentPage - 3 || i === currentPage + 3) {
html += `<span style="color: var(--text-muted); padding: 0 4px;">...</span>`;
}
}
// Next button
html += `<button class="page-btn" ${currentPage === totalPages ? 'disabled' : ''} onclick="changePage(${currentPage + 1})">下页</button>`;
dom.paginationControls.innerHTML = html;
}
window.changePage = function (page) {
currentPage = page;
renderFilteredServers();
};
function resetSort() {
currentSort = { column: 'up', direction: 'desc' };
localStorage.removeItem('serverListSort');
// Clear filters
if (dom.serverSearchFilter) {
dom.serverSearchFilter.value = '';
}
if (dom.sourceFilter) {
dom.sourceFilter.value = 'all';
}
currentSourceFilter = 'all';
// Update UI headers
const headers = document.querySelectorAll('.server-table th.sortable');
headers.forEach(th => {
const col = th.getAttribute('data-sort');
if (col === currentSort.column) {
th.classList.add('active');
th.setAttribute('data-dir', currentSort.direction);
} else {
th.classList.remove('active');
th.removeAttribute('data-dir');
}
});
renderFilteredServers();
}
function handleHeaderSort(column) {
if (currentSort.column === column) {
if (currentSort.direction === 'desc') {
currentSort.direction = 'asc';
} else {
// Cycle back to default (Status desc)
resetSort();
return;
}
} else {
currentSort.column = column;
currentSort.direction = 'desc'; // Default to desc for most metrics
}
// Persist sort state
localStorage.setItem('serverListSort', JSON.stringify(currentSort));
// Update UI headers
const headers = document.querySelectorAll('.server-table th.sortable');
headers.forEach(th => {
const col = th.getAttribute('data-sort');
if (col === currentSort.column) {
th.classList.add('active');
th.setAttribute('data-dir', currentSort.direction);
} else {
th.classList.remove('active');
th.removeAttribute('data-dir');
}
});
renderFilteredServers();
}
// ---- Server Table ----
function updateServerTable(servers) {
if (!servers || servers.length === 0) {
dom.serverTableBody.innerHTML = `
<tr class="empty-row">
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr>
`;
return;
}
dom.serverTableBody.innerHTML = servers.map(server => {
const memPct = server.memPercent || 0;
const diskPct = server.diskPercent || 0;
return `
<tr data-instance="${escapeHtml(server.instance)}" data-job="${escapeHtml(server.job)}" data-source="${escapeHtml(server.source)}" style="cursor: pointer;">
<td>
<span class="status-dot ${server.up ? 'status-dot-online' : 'status-dot-offline'}"></span>
</td>
<td>
<div style="color: var(--text-primary); font-weight: 600; font-family: var(--font-sans);">${escapeHtml(server.job)}</div>
</td>
<td>${escapeHtml(server.source)}</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.cpuPercent, 100)}%"></div>
</div>
<span>${formatPercent(server.cpuPercent)}</span>
</div>
</td>
<td>
<div class="usage-bar">
<div class="usage-bar-track">
<div class="usage-bar-fill usage-bar-fill-mem" style="width: ${Math.min(memPct, 100)}%"></div>
</div>
<span>${formatPercent(memPct)}</span>
</div>
</td>
<td>
<div class="usage-bar">
<div class="usage-bar-track">
<div class="usage-bar-fill usage-bar-fill-disk" style="width: ${Math.min(diskPct, 100)}%"></div>
</div>
<span>${formatPercent(diskPct)}</span>
</div>
</td>
<td>${formatBandwidth(server.netRx)}</td>
<td>${formatBandwidth(server.netTx)}</td>
</tr>
`;
}).join('');
}
// ---- Server Detail ----
async function openServerDetail(instance, job, source) {
// Cleanup old charts if any were still present from a previous open (safety)
if (currentServerDetail.charts) {
Object.values(currentServerDetail.charts).forEach(chart => {
if (chart && chart.destroy) chart.destroy();
});
}
currentServerDetail = { instance, job, source, charts: {} };
dom.serverDetailTitle.textContent = `${job}`;
dom.serverDetailModal.classList.add('active');
// Show loading
dom.detailContainer.style.opacity = '0.3';
dom.detailLoading.style.display = 'block';
dom.detailMetricsList.innerHTML = '';
try {
const url = `/api/metrics/server-details?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}`;
const response = await fetch(url);
if (response.status === 401) {
closeServerDetail();
promptLogin('登录后可查看服务器详细指标');
return;
}
if (!response.ok) throw new Error('Fetch failed');
const data = await response.json();
currentServerDetail.memTotal = data.memTotal;
currentServerDetail.swapTotal = data.swapTotal;
currentServerDetail.rootFsTotal = data.rootFsTotal;
renderServerDetail(data);
} catch (err) {
console.error('Error fetching server details:', err);
} finally {
dom.detailContainer.style.opacity = '1';
dom.detailLoading.style.display = 'none';
}
}
function closeServerDetail() {
dom.serverDetailModal.classList.remove('active');
// Destroy charts
Object.values(currentServerDetail.charts).forEach(chart => {
if (chart && chart.destroy) chart.destroy();
});
currentServerDetail = { instance: null, job: null, source: null, charts: {} };
}
function renderServerDetail(data) {
dom.detailCpuCores.textContent = data.cpuCores + ' 核心';
dom.detailMemTotal.textContent = formatBytes(data.memTotal);
// Uptime formatting
const uptimeSec = data.uptime;
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
dom.detailUptime.textContent = `${days}${hours}小时 ${mins}`;
// Disk Total
if (dom.detailDiskTotal) {
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0);
}
// IP Addresses
const infoGrid = document.getElementById('detailInfoGrid');
if (infoGrid) {
// Remove any previously added IP items
infoGrid.querySelectorAll('.info-item-ip').forEach(el => el.remove());
if (window.SITE_SETTINGS && window.SITE_SETTINGS.show_server_ip) {
if (data.ipv4 && data.ipv4.length > 0) {
const ipv4Item = document.createElement('div');
ipv4Item.className = 'info-item info-item-ip';
ipv4Item.innerHTML = `<span class="info-label">IPv4 地址</span><span class="info-value">${escapeHtml(data.ipv4.join(', '))}</span>`;
infoGrid.appendChild(ipv4Item);
}
if (data.ipv6 && data.ipv6.length > 0) {
const ipv6Item = document.createElement('div');
ipv6Item.className = 'info-item info-item-ip';
ipv6Item.innerHTML = `<span class="info-label">IPv6 地址</span><span class="info-value" style="font-size: 0.65rem; word-break: break-all;">${escapeHtml(data.ipv6.join(', '))}</span>`;
infoGrid.appendChild(ipv6Item);
}
}
}
// Define metrics to show
const cpuValueHtml = `
<div style="display: flex; align-items: baseline; gap: 8px;">
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(data.cpuBusy)}</span>
<span style="font-size: 0.7rem; color: var(--text-secondary); font-weight: normal;">(IO Wait: ${data.cpuIowait.toFixed(1)}%, Busy Others: ${data.cpuOther.toFixed(1)}%)</span>
</div>
`;
// Define metrics to show
const metrics = [
{ key: 'cpuBusy', label: 'CPU 使用率', value: cpuValueHtml },
{ key: 'memUsedPct', label: '内存使用率 (RAM)', value: `
<div style="display: flex; align-items: baseline; gap: 8px;">
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(data.memUsedPct)}</span>
<span style="font-size: 0.7rem; color: var(--text-secondary); font-weight: normal;">(${formatBytes(data.memTotal * data.memUsedPct / 100)} / ${formatBytes(data.memTotal)})</span>
</div>` },
{ key: 'swapUsedPct', label: 'SWAP 使用率', value: formatPercent(data.swapUsedPct) },
{ key: 'rootFsUsedPct', label: '根分区使用率 (/)', value: formatPercent(data.rootFsUsedPct) },
{ key: 'netRx', label: '网络接收速率 (RX)', value: formatBandwidth(data.netRx) },
{ key: 'netTx', label: '网络发送速率 (TX)', value: formatBandwidth(data.netTx) },
{ key: 'sockstatTcp', label: 'TCP 链接数 (Sockstat)', value: data.sockstatTcp.toFixed(0) },
{ key: 'sockstatTcpMem', label: 'TCP 内存占用', value: formatBytes(data.sockstatTcpMem) },
{ key: 'networkTrend', label: '网络流量趋势 (24h)', value: '' }
];
const types = { tx: '上行', rx: '下行', both: '上行+下行', max: '出入取大' };
// Render normal metrics
dom.detailMetricsList.innerHTML = metrics.map(m => `
<div class="metric-item" id="metric-${m.key}">
<div class="metric-item-header" onclick="toggleMetricExpand('${m.key}')">
<div class="metric-label-group">
<span class="metric-label">${m.label}</span>
<span class="metric-value">${m.value}</span>
</div>
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="metric-item-content" id="metrics-content-${m.key}">
<div class="chart-controls">
<div class="time-range-group">
<div class="time-range-selector">
<button class="time-range-btn ${m.key !== 'networkTrend' ? 'active' : ''}" onclick="loadMetricHistory('${m.key}', '1h', event)">1h</button>
<button class="time-range-btn" onclick="loadMetricHistory('${m.key}', '6h', event)">6h</button>
<button class="time-range-btn" onclick="loadMetricHistory('${m.key}', '12h', event)">12h</button>
<button class="time-range-btn ${m.key === 'networkTrend' ? 'active' : ''}" onclick="loadMetricHistory('${m.key}', '24h', event)">24h</button>
</div>
<div class="custom-range-selector">
<input type="text" class="custom-range-input" id="custom-range-${m.key}" placeholder="相对范围 (如: 2h)" onkeydown="if(event.key==='Enter') loadCustomMetricHistory('${m.key}', event)">
</div>
<div class="absolute-range-selector">
<input type="datetime-local" class="time-input" id="start-time-${m.key}">
<span style="color: var(--text-secondary); font-size: 0.7rem;">至</span>
<input type="datetime-local" class="time-input" id="end-time-${m.key}">
<button class="btn-custom-go" onclick="loadCustomMetricHistory('${m.key}', event)">查询</button>
</div>
</div>
</div>
<div class="detail-chart-wrapper" style="${m.key === 'cpuBusy' ? 'height: 260px;' : ''}">
<canvas id="chart-${m.key}"></canvas>
</div>
${m.key === 'networkTrend' ? `
<div class="chart-footer" id="summary-${m.key}" style="display: none; border-top: 1px solid var(--border-color); margin-top: 10px; padding-top: 15px;">
<div class="traffic-stat">
<span class="traffic-label">24h 接收总量</span>
<span class="traffic-value" id="stat-${m.key}-rx">0 B</span>
</div>
<div class="traffic-stat">
<span class="traffic-label">24h 发送总量</span>
<span class="traffic-value" id="stat-${m.key}-tx">0 B</span>
</div>
<div class="traffic-stat traffic-stat-p95">
<span class="traffic-label" id="label-${m.key}-p95">95计费 (${types[window.SITE_SETTINGS?.p95_type || 'tx'] || '上行'})</span>
<span class="traffic-value" id="stat-${m.key}-p95">0 B/s</span>
</div>
<div class="traffic-stat traffic-stat-total">
<span class="traffic-label">24h 总流量</span>
<span class="traffic-value" id="stat-${m.key}-total">0 B</span>
</div>
</div>` : ''}
</div>
</div>
`).join('');
// Render Custom Data
const customDataContainer = dom.customDataContainer;
if (customDataContainer) {
customDataContainer.innerHTML = '';
if (data.custom_data && data.custom_data.length > 0) {
data.custom_data.forEach(item => {
const card = document.createElement('div');
card.className = 'detail-metric-card';
card.style.flex = '1 1 calc(50% - 10px)';
card.innerHTML = `
<span class="detail-metric-label">${item.name}</span>
<span class="detail-metric-value">${item.value || '-'}</span>
`;
customDataContainer.appendChild(card);
});
}
}
// Partitions
if (data.partitions && data.partitions.length > 0) {
dom.detailPartitionsContainer.style.display = 'block';
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
// Find the disk metric item and insert the partition container after it
const diskMetricItem = document.getElementById('metric-rootFsUsedPct');
if (diskMetricItem) {
diskMetricItem.after(dom.detailPartitionsContainer);
}
dom.partitionHeader.onclick = (e) => {
e.stopPropagation();
dom.detailPartitionsContainer.classList.toggle('active');
};
dom.detailPartitionsList.innerHTML = data.partitions.map(p => `
<div class="partition-row">
<div class="partition-info">
<span class="partition-mount">${escapeHtml(p.mountpoint)}</span>
<span class="partition-usage-text">${formatBytes(p.used)} / ${formatBytes(p.size)} (${formatPercent(p.percent)})</span>
</div>
<div class="partition-progress">
<div class="partition-bar" style="width: ${Math.min(p.percent, 100)}%; background-color: ${getUsageColor(p.percent)};"></div>
</div>
</div>
`).join('');
} else {
dom.detailPartitionsContainer.style.display = 'none';
}
}
window.toggleMetricExpand = async function (metricKey) {
const el = document.getElementById(`metric-${metricKey}`);
const wasActive = el.classList.contains('active');
// Close all others
document.querySelectorAll('.metric-item').forEach(item => item.classList.remove('active'));
if (!wasActive) {
el.classList.add('active');
// Initial load
const defaultRange = metricKey === 'networkTrend' ? '24h' : '1h';
loadMetricHistory(metricKey, defaultRange);
}
};
window.loadMetricHistory = async function (metricKey, range, event, start = null, end = null) {
if (event) {
event.stopPropagation();
const group = event.target.closest('.time-range-group');
if (group) {
group.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
if (event.target.classList.contains('time-range-btn')) {
event.target.classList.add('active');
}
}
}
const canvas = document.getElementById(`chart-${metricKey}`);
if (!canvas) return;
let chart = currentServerDetail.charts[metricKey];
if (!chart) {
let unit = '';
if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
if (metricKey.startsWith('net')) unit = 'B/s';
if (metricKey === 'sockstatTcpMem') unit = 'B';
if (metricKey === 'networkTrend') {
chart = new AreaChart(canvas);
chart.padding = { top: 15, right: 15, bottom: 35, left: 65 };
} else {
chart = new MetricChart(canvas, unit);
}
currentServerDetail.charts[metricKey] = chart;
}
// 为百分比图表设置总量,允许 Y 轴显示实际物理占用数值
if (metricKey === 'memUsedPct') chart.totalValue = currentServerDetail.memTotal;
if (metricKey === 'swapUsedPct') chart.totalValue = currentServerDetail.swapTotal;
if (metricKey === 'rootFsUsedPct') chart.totalValue = currentServerDetail.rootFsTotal;
try {
const { instance, job, source } = currentServerDetail;
let url = `/api/metrics/server-history?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}&metric=${metricKey}`;
if (start && end) {
url += `&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
} else {
url += `&range=${range}`;
}
const res = await fetch(url);
if (res.status === 401) {
promptLogin('登录后可查看历史曲线与服务器详细信息');
return;
}
if (!res.ok) throw new Error('Query failed');
const data = await res.json();
if (metricKey === 'networkTrend' && data.stats) {
const stats = data.stats;
const summaryDiv = document.getElementById(`summary-${metricKey}`);
if (summaryDiv) {
summaryDiv.style.display = 'flex';
const rxEl = document.getElementById(`stat-${metricKey}-rx`);
const txEl = document.getElementById(`stat-${metricKey}-tx`);
const p95El = document.getElementById(`stat-${metricKey}-p95`);
const totalEl = document.getElementById(`stat-${metricKey}-total`);
if (rxEl) rxEl.textContent = formatBytes(stats.rxTotal);
if (txEl) txEl.textContent = formatBytes(stats.txTotal);
if (p95El) p95El.textContent = formatBandwidth(stats.p95);
if (totalEl) totalEl.textContent = formatBytes(stats.total);
const p95Label = document.getElementById(`label-${metricKey}-p95`);
if (p95Label) {
const types = { tx: '上行', rx: '下行', both: '上行+下行', max: '出入取大' };
p95Label.textContent = `95计费 (${types[window.SITE_SETTINGS?.p95_type || 'tx'] || '上行'})`;
}
}
}
chart.setData(data);
} catch (err) {
console.error(`Error loading history for ${metricKey}:`, err);
}
};
window.loadCustomMetricHistory = async function (metricKey, event) {
if (event) event.stopPropagation();
const rangeInput = document.getElementById(`custom-range-${metricKey}`);
const startInput = document.getElementById(`start-time-${metricKey}`);
const endInput = document.getElementById(`end-time-${metricKey}`);
const range = (rangeInput.value || '').trim().toLowerCase();
const startTime = startInput.value;
const endTime = endInput.value;
if (startTime && endTime) {
// Absolute range
loadMetricHistory(metricKey, null, event, startTime, endTime);
} else if (range) {
// Relative range
if (!/^\d+[smhd]$/.test(range)) {
alert('格式不正确,请使用如: 2h, 30m, 1d 等格式');
return;
}
loadMetricHistory(metricKey, range, event);
} else {
alert('请输入相对范围或选择具体时间范围');
}
};
// ---- Network History ----
async function fetchNetworkHistory(force = false) {
try {
const url = `/api/metrics/network-history${force ? '?force=true' : ''}`;
const response = await fetch(url);
const data = await response.json();
networkChart.setData(data);
if (dom.trafficP95 && networkChart.p95) {
dom.trafficP95.textContent = formatBandwidth(networkChart.p95);
}
} catch (err) {
console.error('Error fetching network history:', err);
}
}
// ---- Settings Modal ----
function openSettings() {
dom.settingsModal.classList.add('active');
loadSources();
loadLatencyRoutes();
}
function closeSettings() {
dom.settingsModal.classList.remove('active');
hideMessage();
hideSiteMessage();
hideChangePasswordMessage();
// Reset password fields
if (dom.oldPasswordInput) dom.oldPasswordInput.value = '';
if (dom.newPasswordInput) dom.newPasswordInput.value = '';
if (dom.confirmNewPasswordInput) dom.confirmNewPasswordInput.value = '';
}
// ---- Tab Switching ----
function switchTab(tabId) {
dom.modalTabs.forEach(tab => {
tab.classList.toggle('active', tab.getAttribute('data-tab') === tabId);
});
dom.tabContents.forEach(content => {
content.classList.toggle('active', content.id === `tab-${tabId}`);
});
}
// ---- Site Settings ----
async function loadSiteSettings() {
try {
const response = await fetch('/api/settings');
const settings = await response.json();
window.SITE_SETTINGS = settings; // Cache it globally
// Update inputs
dom.pageNameInput.value = settings.page_name || '';
if (settings.title) dom.siteTitleInput.value = settings.title;
if (settings.logo_url) dom.logoUrlInput.value = settings.logo_url;
if (settings.default_theme) dom.defaultThemeInput.value = settings.default_theme;
if (settings.show_95_bandwidth !== undefined) {
dom.show95BandwidthInput.value = settings.show_95_bandwidth ? "1" : "0";
if (networkChart) {
networkChart.showP95 = !!settings.show_95_bandwidth;
networkChart.p95Type = settings.p95_type || 'tx';
if (dom.legendP95) {
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
}
if (dom.p95LabelText) {
const types = { tx: '上行', rx: '下行', both: '上行+下行', max: '出入取大' };
dom.p95LabelText.textContent = types[networkChart.p95Type] || '上行';
// Also update the static label in the chart footer
const trafficP95Label = document.querySelector('.chart-card-wide .traffic-stat-p95 .traffic-label');
if (trafficP95Label) {
trafficP95Label.textContent = `95计费 (${dom.p95LabelText.textContent})`;
}
}
networkChart.draw();
}
}
if (dom.icpFilingInput) dom.icpFilingInput.value = settings.icp_filing || '';
if (dom.psFilingInput) dom.psFilingInput.value = settings.ps_filing || '';
if (dom.logoUrlDarkInput) dom.logoUrlDarkInput.value = settings.logo_url_dark || '';
if (dom.faviconUrlInput) dom.faviconUrlInput.value = settings.favicon_url || '';
if (dom.showPageNameInput) dom.showPageNameInput.value = settings.show_page_name !== undefined ? settings.show_page_name.toString() : "1";
if (dom.requireLoginForServerDetailsInput) dom.requireLoginForServerDetailsInput.value = settings.require_login_for_server_details ? "1" : "0";
// Handle network data sources checkboxes
if (settings.network_data_sources) {
const selected = settings.network_data_sources.split(',').map(s => s.trim());
const checkboxes = dom.networkSourceSelector.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => {
cb.checked = selected.includes(cb.value);
});
// We'll also store this in a temporary place because loadSources might run later
dom.networkSourceSelector.dataset.pendingSelected = settings.network_data_sources;
} else {
const checkboxes = dom.networkSourceSelector.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = false);
dom.networkSourceSelector.dataset.pendingSelected = "";
}
// Handle Theme Priority: localStorage > Site Default
const savedTheme = localStorage.getItem('theme');
const themeToApply = savedTheme || settings.default_theme || 'dark';
applyTheme(themeToApply);
// Listen for system theme changes if set to auto (cleanup existing listener first)
if (siteThemeQuery && siteThemeHandler) {
siteThemeQuery.removeEventListener('change', siteThemeHandler);
}
siteThemeQuery = window.matchMedia('(prefers-color-scheme: light)');
siteThemeHandler = () => {
const currentSavedTheme = localStorage.getItem('theme');
const defaultTheme = window.SITE_SETTINGS ? window.SITE_SETTINGS.default_theme : 'dark';
const activeTheme = currentSavedTheme || defaultTheme;
if (activeTheme === 'auto') {
applyTheme('auto');
}
};
siteThemeQuery.addEventListener('change', siteThemeHandler);
// Update IP visibility input
if (dom.showServerIpInput) dom.showServerIpInput.value = settings.show_server_ip ? "1" : "0";
if (dom.ipMetricNameInput) dom.ipMetricNameInput.value = settings.ip_metric_name || '';
if (dom.ipLabelNameInput) dom.ipLabelNameInput.value = settings.ip_label_name || '';
// Load Custom Metrics
loadCustomMetricsUI(settings.custom_metrics);
// Sync security tab dependency
updateSecurityDependency();
} catch (err) {
console.error('Error loading site settings:', err);
}
}
function applySiteSettings(settings) {
if (settings.page_name) {
document.title = settings.page_name;
}
if (dom.logoText) {
if (settings.title) dom.logoText.textContent = settings.title;
// Handle visibility toggle
dom.logoText.style.display = (settings.show_page_name === 0) ? 'none' : 'block';
}
// Logo Icon
let logoToUse = settings.logo_url;
const currentTheme = document.documentElement.classList.contains('light-theme') ? 'light' : 'dark';
if (currentTheme === 'dark' && settings.logo_url_dark) {
logoToUse = settings.logo_url_dark;
}
renderLogoImage(logoToUse || null);
// Favicon
updateFavicon(settings.favicon_url);
// P95 setting
if (settings.show_95_bandwidth !== undefined || settings.p95_type !== undefined) {
if (networkChart) {
if (settings.show_95_bandwidth !== undefined) {
networkChart.showP95 = !!settings.show_95_bandwidth;
if (dom.legendP95) {
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
}
}
if (settings.p95_type !== undefined) {
networkChart.p95Type = settings.p95_type;
if (dom.p95LabelText) {
const types = { tx: '上行', rx: '下行', both: '上行+下行', max: '出入取大' };
dom.p95LabelText.textContent = types[settings.p95_type] || '上行';
// Also update the static label in the chart footer
const trafficP95Label = document.querySelector('.chart-card-wide .traffic-stat-p95 .traffic-label');
if (trafficP95Label) {
trafficP95Label.textContent = `95计费 (${dom.p95LabelText.textContent})`;
}
}
}
networkChart.draw();
}
}
// Default Theme
if (settings.default_theme) {
if (dom.defaultThemeInput) dom.defaultThemeInput.value = settings.default_theme;
}
// Filing info
let hasPs = !!settings.ps_filing;
let hasIcp = !!settings.icp_filing;
if (dom.psFilingDisplay) {
if (hasPs) {
if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing;
dom.psFilingDisplay.style.display = 'inline-block';
} else {
dom.psFilingDisplay.style.display = 'none';
}
}
if (dom.icpFilingDisplay) {
if (hasIcp) {
dom.icpFilingDisplay.textContent = settings.icp_filing;
dom.icpFilingDisplay.style.display = 'inline-block';
} else {
dom.icpFilingDisplay.style.display = 'none';
}
}
// Handle separator
const filingSep = document.querySelector('.filing-sep');
if (filingSep) {
// Small adjustment: the CSS will handle the PC-only display,
// here we just handle the logical "both exist" requirement.
filingSep.style.display = (hasPs && hasIcp) ? '' : 'none';
}
const footerContent = document.querySelector('.footer-content');
if (footerContent) {
footerContent.classList.toggle('only-copyright', !(hasPs || hasIcp));
}
}
async function saveSiteSettings() {
if (!user) {
showSiteMessage('Please login first', 'error');
openLoginModal();
return;
}
const settings = {
page_name: dom.pageNameInput.value.trim(),
title: dom.siteTitleInput ? dom.siteTitleInput.value.trim() : dom.pageNameInput.value.trim(),
logo_url: dom.logoUrlInput ? dom.logoUrlInput.value.trim() : '',
logo_url_dark: dom.logoUrlDarkInput ? dom.logoUrlDarkInput.value.trim() : '',
favicon_url: dom.faviconUrlInput ? dom.faviconUrlInput.value.trim() : '',
show_page_name: dom.showPageNameInput ? parseInt(dom.showPageNameInput.value) : 1,
require_login_for_server_details: dom.requireLoginForServerDetailsInput ? (dom.requireLoginForServerDetailsInput.value === "1") : true,
default_theme: dom.defaultThemeInput ? dom.defaultThemeInput.value : 'dark',
show_95_bandwidth: dom.show95BandwidthInput ? (dom.show95BandwidthInput.value === "1") : false,
p95_type: dom.p95TypeSelect ? dom.p95TypeSelect.value : 'tx',
ps_filing: dom.psFilingInput ? dom.psFilingInput.value.trim() : '',
icp_filing: dom.icpFilingInput ? dom.icpFilingInput.value.trim() : '',
network_data_sources: Array.from(dom.networkSourceSelector.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).join(','),
show_server_ip: dom.showServerIpInput ? (dom.showServerIpInput.value === "1") : false,
ip_metric_name: dom.ipMetricNameInput ? dom.ipMetricNameInput.value.trim() : null,
ip_label_name: dom.ipLabelNameInput ? dom.ipLabelNameInput.value.trim() : 'address',
custom_metrics: getCustomMetricsFromUI()
};
// UI Feedback for both potential save buttons
const saveButtons = [dom.btnSaveSiteSettings, dom.btnSaveSecuritySettings].filter(b => b);
saveButtons.forEach(btn => {
btn.disabled = true;
btn.originalText = btn.textContent;
btn.textContent = '保存中...';
});
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
showSiteMessage('设置保存成功', 'success');
// Update global object and UI immediately
window.SITE_SETTINGS = { ...window.SITE_SETTINGS, ...settings };
const savedTheme = localStorage.getItem('theme');
const themeToApply = savedTheme || settings.default_theme || 'dark';
applyTheme(themeToApply);
// Apply settings to UI (logo, name, etc.)
applySiteSettings(window.SITE_SETTINGS);
// Refresh overview and historical charts to reflect new source selections
fetchNetworkHistory(true);
// We can't force the WS broadcast easily from client,
// but we can fetch the overview via REST API once to update UI
fetch('/api/metrics/overview?force=true')
.then(res => res.json())
.then(data => updateDashboard(data))
.catch(() => {});
} else {
const err = await response.json();
showSiteMessage(`保存失败: ${err.error || '未知错误'}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
showSiteMessage(`保存失败: ${err.message}`, 'error');
console.error('Save settings error:', err);
} finally {
saveButtons.forEach(btn => {
btn.disabled = false;
btn.textContent = btn.originalText || '保存设置';
});
}
}
function updateSecurityDependency() {
if (!dom.requireLoginForServerDetailsInput || !dom.showServerIpInput) return;
const requireLogin = dom.requireLoginForServerDetailsInput.value === "1";
if (!requireLogin) {
// If public access is allowed, force hide IP and disable the toggle
dom.showServerIpInput.value = "0";
dom.showServerIpInput.disabled = true;
dom.showServerIpInput.style.opacity = "0.6";
dom.showServerIpInput.parentElement.style.opacity = "0.7";
} else {
// Re-enable when login is required
dom.showServerIpInput.disabled = false;
dom.showServerIpInput.style.opacity = "1";
dom.showServerIpInput.parentElement.style.opacity = "1";
}
}
// ---- Latency Routes ----
async function loadLatencyRoutes() {
try {
const response = await fetch('/api/latency-routes');
if (response.status === 401) {
promptLogin('登录后可管理延迟线路');
return;
}
const routes = await response.json();
renderLatencyRoutes(routes);
} catch (err) {
console.error('Error loading latency routes:', err);
}
}
function renderLatencyRoutes(routes) {
if (routes.length === 0) {
dom.latencyRoutesList.innerHTML = `<div class="route-empty" style="text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; background: rgba(0,0,0,0.1); border-radius: 8px;">暂无线路</div>`;
return;
}
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="route-info" style="display: flex; flex-direction: column; gap: 4px;">
<div style="font-weight: 600; font-size: 0.88rem; color: var(--text-primary);">
${escapeHtml(route.latency_source)}${escapeHtml(route.latency_dest)}
</div>
<div style="font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 10px;">
<span>数据源: ${escapeHtml(route.source_name || '已删除')}</span>
<span>目标: ${escapeHtml(route.latency_target)}</span>
</div>
</div>
<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-delete" onclick="deleteLatencyRoute(${route.id})" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
</div>
</div>
`).join('');
}
window.editRoute = function (id, source_id, source, dest, target) {
editingRouteId = id;
dom.routeSourceSelect.value = source_id;
dom.routeSourceInput.value = source;
dom.routeDestInput.value = dest;
dom.routeTargetInput.value = target;
dom.btnAddRoute.textContent = '保存修改';
dom.btnCancelEditRoute.style.display = 'block';
// Select the tab just in case (though it's already there)
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
if (tab) tab.click();
};
function cancelEditRoute() {
editingRouteId = null;
dom.routeSourceSelect.value = "";
dom.routeSourceInput.value = "";
dom.routeDestInput.value = "";
dom.routeTargetInput.value = "";
dom.btnAddRoute.textContent = '添加线路';
dom.btnCancelEditRoute.style.display = 'none';
}
async function addLatencyRoute() {
if (!user) {
showSiteMessage('请先登录及进行身份验证', 'error');
openLoginModal();
return;
}
const source_id = dom.routeSourceSelect.value;
const latency_source = dom.routeSourceInput.value.trim();
const latency_dest = dom.routeDestInput.value.trim();
const latency_target = dom.routeTargetInput.value.trim();
if (!source_id || !latency_source || !latency_dest || !latency_target) {
showSiteMessage('请填写完整的线路信息', 'error');
return;
}
const url = editingRouteId ? `/api/latency-routes/${editingRouteId}` : '/api/latency-routes';
const method = editingRouteId ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: parseInt(source_id), latency_source, latency_dest, latency_target })
});
if (response.ok) {
showSiteMessage(editingRouteId ? '线路更新成功' : '线路添加成功', 'success');
cancelEditRoute();
loadLatencyRoutes();
fetchLatency();
} else {
const err = await response.json();
showSiteMessage(`操作失败: ${err.error}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
console.error('Error adding latency route:', err);
}
}
window.deleteLatencyRoute = async function (id) {
if (!user) {
showSiteMessage('请登录后再操作', 'error');
openLoginModal();
return;
}
if (!confirm('确定要删除这条延迟线路吗?')) return;
try {
const response = await fetch(`/api/latency-routes/${id}`, { method: 'DELETE' });
if (response.ok) {
loadLatencyRoutes();
fetchLatency();
} else {
if (response.status === 401) openLoginModal();
}
} catch (err) {
console.error('Error deleting latency route:', err);
}
};
function showSiteMessage(text, type) {
dom.siteSettingsMessage.textContent = text;
dom.siteSettingsMessage.className = `form-message ${type}`;
setTimeout(hideSiteMessage, 5000);
}
function hideSiteMessage() {
dom.siteSettingsMessage.className = 'form-message';
}
async function saveChangePassword() {
if (!user) {
showChangePasswordMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
const oldPassword = dom.oldPasswordInput.value;
const newPassword = dom.newPasswordInput.value;
const confirmNewPassword = dom.confirmNewPasswordInput.value;
if (!oldPassword || !newPassword || !confirmNewPassword) {
showChangePasswordMessage('请填写所有密码字段', 'error');
return;
}
if (newPassword !== confirmNewPassword) {
showChangePasswordMessage('两次输入的新密码不一致', 'error');
return;
}
if (newPassword.length < 6) {
showChangePasswordMessage('新密码长度至少为 6 位', 'error');
return;
}
dom.btnChangePassword.disabled = true;
dom.btnChangePassword.textContent = '提交中...';
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
oldPassword,
newPassword
})
});
const data = await response.json();
if (response.ok) {
showChangePasswordMessage('密码修改成功', 'success');
dom.oldPasswordInput.value = '';
dom.newPasswordInput.value = '';
dom.confirmNewPasswordInput.value = '';
} else {
showChangePasswordMessage(data.error || '修改失败', 'error');
if (response.status === 401 && data.error === 'Auth required') openLoginModal();
}
} catch (err) {
showChangePasswordMessage(`请求失败: ${err.message}`, 'error');
} finally {
dom.btnChangePassword.disabled = false;
dom.btnChangePassword.textContent = '提交修改';
}
}
function showChangePasswordMessage(text, type) {
dom.changePasswordMessage.textContent = text;
dom.changePasswordMessage.className = `form-message ${type}`;
setTimeout(hideChangePasswordMessage, 5000);
}
function hideChangePasswordMessage() {
dom.changePasswordMessage.className = 'form-message';
}
function updateSourceFilterOptions(sources) {
if (dom.sourceFilter) {
const current = dom.sourceFilter.value;
let html = '<option value="all">所有数据源</option>';
const displaySources = sources.filter(s => s.type !== 'blackbox');
displaySources.forEach(source => {
html += `<option value="${escapeHtml(source.name)}">${escapeHtml(source.name)}</option>`;
});
dom.sourceFilter.innerHTML = html;
if (displaySources.some(s => s.name === current)) {
dom.sourceFilter.value = current;
} else {
dom.sourceFilter.value = 'all';
currentSourceFilter = 'all';
}
}
if (dom.routeSourceSelect) {
const currentVal = dom.routeSourceSelect.value;
const blackboxSources = sources.filter(s => s.type === 'blackbox');
dom.routeSourceSelect.innerHTML = '<option value="">-- 选择数据源 --</option>' +
blackboxSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
dom.routeSourceSelect.value = currentVal;
}
}
async function loadSources() {
try {
const response = await fetch('/api/sources');
if (response.status === 401) {
promptLogin('登录后可查看和管理数据源');
return;
}
const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : [];
const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`;
updateSourceFilterOptions(sourcesArray);
renderSources(sourcesArray);
renderNetworkSourceSelector(sourcesArray);
} catch (err) {
console.error('Error loading sources:', err);
}
}
function renderSources(sources) {
if (sources.length === 0) {
dom.sourceItems.innerHTML = '<div class="source-empty">暂无数据源</div>';
return;
}
dom.sourceItems.innerHTML = sources.map(source => `
<div class="source-item" data-id="${source.id}">
<div class="source-item-info">
<div class="source-item-name">
${escapeHtml(source.name)}
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
${source.status === 'online' ? '在线' : '离线'}
</span>
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
</span>
</div>
<div class="source-item-url">${escapeHtml(source.url)}</div>
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
</div>
<div class="source-item-actions">
<button class="btn btn-secondary btn-sm" onclick="editSource(${JSON.stringify(source).replace(/"/g, '&quot;')})">编辑</button>
<button class="btn btn-delete btn-sm" onclick="deleteSource(${source.id})">删除</button>
</div>
</div>
`).join('');
}
function renderNetworkSourceSelector(sources) {
if (!dom.networkSourceSelector) return;
// Only show Prometheus sources for filtering
const promSources = sources.filter(s => s.type !== 'blackbox');
if (promSources.length === 0) {
dom.networkSourceSelector.innerHTML = '<div style="color: var(--text-muted); font-size: 0.9rem;">暂无可用数据源</div>';
return;
}
const pendingSelected = dom.networkSourceSelector.dataset.pendingSelected ? dom.networkSourceSelector.dataset.pendingSelected.split(',').map(s => s.trim()) : [];
dom.networkSourceSelector.innerHTML = promSources.map(source => `
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 4px 8px; border-radius: 4px; background: rgba(255,255,255,0.05); font-size: 0.9rem;">
<input type="checkbox" value="${escapeHtml(source.name)}" ${pendingSelected.includes(source.name) ? 'checked' : ''} style="cursor: pointer;">
<span>${escapeHtml(source.name)}</span>
</label>
`).join('');
}
// ---- Test Connection ----
async function testConnection() {
const url = dom.sourceUrl.value.trim();
if (!url) {
showMessage('请输入 Prometheus URL', 'error');
return;
}
const type = dom.sourceType.value;
dom.btnTest.textContent = '测试中...';
dom.btnTest.disabled = true;
try {
const response = await fetch('/api/sources/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, type })
});
const data = await response.json();
if (data.status === 'ok') {
showMessage(`连接成功Prometheus 版本: ${data.version}`, 'success');
} else {
if (response.status === 401) {
showMessage('请先登录', 'error');
openLoginModal();
} else {
showMessage(`连接失败: ${data.message || data.error}`, 'error');
}
}
} catch (err) {
showMessage(`连接失败: ${err.message}`, 'error');
} finally {
dom.btnTest.textContent = '测试连接';
dom.btnTest.disabled = false;
}
}
// ---- Add Source ----
let editingSourceId = null;
window.editSource = function(source) {
editingSourceId = source.id;
dom.sourceName.value = source.name || '';
dom.sourceUrl.value = source.url || '';
dom.sourceType.value = source.type || 'prometheus';
dom.sourceDesc.value = source.description || '';
dom.isServerSource.checked = !!source.is_server_source;
// Toggle Blackbox UI
if (source.type === 'blackbox') {
dom.serverSourceOption.style.display = 'none';
} else {
dom.serverSourceOption.style.display = 'flex';
}
dom.btnAdd.textContent = '保存修改';
// Add cancel button if not already there
if (!document.getElementById('btnCancelEditSource')) {
const cancelBtn = document.createElement('button');
cancelBtn.id = 'btnCancelEditSource';
cancelBtn.className = 'btn btn-secondary';
cancelBtn.style.marginLeft = '8px';
cancelBtn.textContent = '取消';
cancelBtn.onclick = cancelEditSource;
dom.btnAdd.parentNode.appendChild(cancelBtn);
}
// Scroll to form
dom.sourceName.focus();
};
function cancelEditSource() {
editingSourceId = null;
dom.sourceName.value = '';
dom.sourceUrl.value = '';
dom.sourceType.value = 'prometheus';
dom.sourceDesc.value = '';
dom.isServerSource.checked = true;
dom.serverSourceOption.style.display = 'flex';
dom.btnAdd.textContent = '添加';
const cancelBtn = document.getElementById('btnCancelEditSource');
if (cancelBtn) cancelBtn.remove();
hideMessage();
}
async function addSource() {
if (!user) {
showMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
const name = dom.sourceName.value.trim();
const url = dom.sourceUrl.value.trim();
const type = dom.sourceType.value;
const description = dom.sourceDesc.value.trim();
// Default to false for blackbox, otherwise use checkbox
const is_server_source = type === 'blackbox' ? false : dom.isServerSource.checked;
if (!name || !url) {
showMessage('请填写名称和URL', 'error');
return;
}
const isEditing = editingSourceId !== null;
dom.btnAdd.textContent = isEditing ? '保存中...' : '添加中...';
dom.btnAdd.disabled = true;
try {
const urlPath = isEditing ? `/api/sources/${editingSourceId}` : '/api/sources';
const method = isEditing ? 'PUT' : 'POST';
const response = await fetch(urlPath, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, url, description, is_server_source, type })
});
if (response.ok) {
showMessage(isEditing ? '数据源更新成功' : '数据源添加成功', 'success');
if (isEditing) {
cancelEditSource();
} else {
dom.sourceName.value = '';
dom.sourceUrl.value = '';
dom.sourceDesc.value = '';
}
loadSources();
fetchMetrics();
fetchNetworkHistory();
} else {
const err = await response.json();
showMessage(`添加失败: ${err.error}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
showMessage(`添加失败: ${err.message}`, 'error');
} finally {
dom.btnAdd.textContent = '添加';
dom.btnAdd.disabled = false;
}
}
// ---- Delete Source ----
window.deleteSource = async function (id) {
if (!user) {
showMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
if (!confirm('确定要删除这个数据源吗?')) return;
try {
const response = await fetch(`/api/sources/${id}`, { method: 'DELETE' });
if (response.ok) {
loadSources();
fetchMetrics();
fetchNetworkHistory();
} else {
if (response.status === 401) openLoginModal();
}
} catch (err) {
console.error('Error deleting source:', err);
}
};
// ---- Messages ----
function showMessage(text, type) {
dom.formMessage.textContent = text;
dom.formMessage.className = `form-message ${type}`;
setTimeout(hideMessage, 5000);
}
function hideMessage() {
dom.formMessage.className = 'form-message';
}
// ---- Escape HTML ----
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// ---- Load source count on page load ----
async function loadSourceCount() {
try {
const response = await fetch('/api/sources');
if (response.status === 401) {
return;
}
const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : [];
const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${promSources.length} 数据源)`;
updateSourceFilterOptions(sourcesArray);
} catch (err) {
// ignore
}
}
// ---- Start ----
loadSourceCount();
init();
})();