/** * 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'), 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'), // 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 || ''; // Latency routes loaded separately in openSettings or on startup } loadSiteSettings(); // 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(memPct)}
${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); } // 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.label} ${m.value}
${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); } 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(',') }; dom.btnSaveSiteSettings.disabled = true; dom.btnSaveSiteSettings.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 { dom.btnSaveSiteSettings.disabled = false; dom.btnSaveSiteSettings.textContent = '保存设置'; } } // ---- 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(); })();