/**
* 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'),
psFilingDisplay: document.getElementById('psFilingDisplay'),
psFilingText: document.getElementById('psFilingText'),
copyrightYear: document.getElementById('copyrightYear')
};
// ---- 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);
// 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;
}
dom.settingsModal.addEventListener('click', (e) => {
if (e.target === dom.settingsModal) closeSettings();
});
dom.btnTest.addEventListener('click', testConnection);
dom.btnAdd.addEventListener('click', addSource);
// Auth & Theme listeners
dom.themeToggle.addEventListener('change', toggleTheme);
// System Theme Listener (Real-time)
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: light)');
if (systemThemeMedia.addEventListener) {
systemThemeMedia.addEventListener('change', () => {
const savedTheme = localStorage.getItem('theme') || (window.SITE_SETTINGS && window.SITE_SETTINGS.default_theme) || 'dark';
if (savedTheme === 'auto') {
applyTheme('auto');
}
});
}
dom.btnLogin.addEventListener('click', openLoginModal);
dom.closeLoginModal.addEventListener('click', closeLoginModal);
dom.loginForm.addEventListener('submit', handleLogin);
dom.loginModal.addEventListener('click', (e) => {
if (e.target === dom.loginModal) closeLoginModal();
});
// Tab switching
dom.modalTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab');
switchTab(targetTab);
});
});
// Site settings
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
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 = `
`;
// 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 = `
`;
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();
}
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 = `
${escapeHtml(username)}
`;
document.getElementById('btnLogout').addEventListener('click', handleLogout);
} else {
user = null;
dom.btnSettings.style.display = 'none';
dom.userSection.innerHTML = ``;
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 = `
`;
}
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 = `地图库加载失败
`;
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 `
${escapeHtml(d.job)}
${escapeHtml(d.city || '')}, ${escapeHtml(d.countryName || d.country || '')}
BW: ↓${formatBandwidth(d.netRx)} ↑${formatBandwidth(d.netTx)}
`;
}
},
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 = 'Map data unavailable
';
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 `
${r.source} ↔ ${r.dest}
Latency: ${latVal}
`;
}
},
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: `
${formatPercent(server.cpuPercent)}
` },
{ key: 'memUsedPct', label: '内存使用率 (RAM)', value: `
${formatPercent(server.memPercent)}
(${formatBytes(server.memUsed)} / ${formatBytes(server.memTotal)})
` },
{ 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 += ``;
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
html += ``;
} else if (i === currentPage - 3 || i === currentPage + 3) {
html += `...`;
}
}
// Next button
html += ``;
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 = `
| 暂无数据 - 请先配置 Prometheus 数据源 |
`;
return;
}
dom.serverTableBody.innerHTML = servers.map(server => {
const memPct = server.memPercent || 0;
const diskPct = server.diskPercent || 0;
return `
|
|
${escapeHtml(server.job)}
|
${escapeHtml(server.source)} |
${formatPercent(server.cpuPercent)}
|
|
${formatPercent(diskPct)}
|
${formatBandwidth(server.netRx)} |
${formatBandwidth(server.netTx)} |
`;
}).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 = `IPv4 地址${escapeHtml(data.ipv4.join(', '))}`;
infoGrid.appendChild(ipv4Item);
}
if (data.ipv6 && data.ipv6.length > 0) {
const ipv6Item = document.createElement('div');
ipv6Item.className = 'info-item info-item-ip';
ipv6Item.innerHTML = `IPv6 地址${escapeHtml(data.ipv6.join(', '))}`;
infoGrid.appendChild(ipv6Item);
}
}
}
// Define metrics to show
const cpuValueHtml = `
${formatPercent(data.cpuBusy)}
(IO Wait: ${data.cpuIowait.toFixed(1)}%, Busy Others: ${data.cpuOther.toFixed(1)}%)
`;
// Define metrics to show
const metrics = [
{ key: 'cpuBusy', label: 'CPU 使用率', value: cpuValueHtml },
{ key: 'memUsedPct', label: '内存使用率 (RAM)', value: `
${formatPercent(data.memUsedPct)}
(${formatBytes(data.memTotal * data.memUsedPct / 100)} / ${formatBytes(data.memTotal)})
` },
{ 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 => `
${m.key === 'networkTrend' ? `
` : ''}
`).join('');
// Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric
if (data.partitions && data.partitions.length > 0) {
dom.detailPartitionsContainer.style.display = 'block';
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
// 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 => `
${escapeHtml(p.mountpoint)}
${formatBytes(p.used)} / ${formatBytes(p.size)} (${formatPercent(p.percent)})
`).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 || 'address';
// 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('请先登录后操作', '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'
};
// 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 = `暂无线路
`;
return;
}
dom.latencyRoutesList.innerHTML = routes.map(route => `
${escapeHtml(route.latency_source)} ↔ ${escapeHtml(route.latency_dest)}
数据源: ${escapeHtml(route.source_name || '已删除')}
目标: ${escapeHtml(route.latency_target)}
`).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 = '';
const displaySources = sources.filter(s => s.type !== 'blackbox');
displaySources.forEach(source => {
html += ``;
});
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 = '' +
blackboxSources.map(s => ``).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 = '暂无数据源
';
return;
}
dom.sourceItems.innerHTML = sources.map(source => `
${escapeHtml(source.name)}
${source.status === 'online' ? '在线' : '离线'}
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
${escapeHtml(source.url)}
${source.description ? `
${escapeHtml(source.description)}
` : ''}
`).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 = '暂无可用数据源
';
return;
}
const pendingSelected = dom.networkSourceSelector.dataset.pendingSelected ? dom.networkSourceSelector.dataset.pendingSelected.split(',').map(s => s.trim()) : [];
dom.networkSourceSelector.innerHTML = promSources.map(source => `
`).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();
})();