/** * 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'), // 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'), // 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'), 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') }; // ---- 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 // 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; // ---- Initialize ---- function init() { // Resource Gauges Time updateGaugesTime(); setInterval(updateGaugesTime, 1000); // 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); 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(); } }); } // 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) { 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'; // 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 || []; updateDashboard(msg.data); updateMap2D(allServersData); } } 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); } 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); } 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 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(); } 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() { try { const response = await fetch('/api/metrics/overview'); 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 const resp = await fetch('https://cdn.jsdelivr.net/npm/echarts@4.9.0/map/json/world.json'); const worldJSON = await resp.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', shadowBlur: 3, shadowColor: 'rgba(6, 182, 212, 0.5)' }, 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); } } 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' } } } }); } function updateMap2D(servers) { if (!myMap2D) return; // Shift longitude for Pacific-centered view const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng); 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 })); // Combine all series const finalSeries = [ { type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, symbolSize: 6, itemStyle: { color: '#06b6d4', shadowBlur: 3, shadowColor: 'rgba(6, 182, 212, 0.5)' }, data: geoData, zlevel: 1 } ]; // Add latency routes if configured if (currentLatencies && currentLatencies.length > 0) { const countryCoords = { 'china': [116.4074, 39.9042], 'beijing': [116.4074, 39.9042], 'shanghai': [121.4737, 31.2304], 'hong kong': [114.1694, 22.3193], 'taiwan': [120.9605, 23.6978], 'united states': [-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], 'news york': [-74.0060, 40.7128], 'new york corp': [-74.0060, 40.7128], 'new york': [-74.0060, 40.7128], 'san francisco': [-122.4194, 37.7749], 'los angeles': [-118.2437, 34.0522], 'japan': [138.2529, 36.2048], 'tokyo': [139.6917, 35.6895], 'singapore': [103.8198, 1.3521], 'germany': [10.4515, 51.1657], 'frankfurt': [8.6821, 50.1109], 'united kingdom': [-3.436, 55.3781], 'london': [-0.1276, 51.5074], 'france': [2.2137, 46.2276], 'paris': [2.3522, 48.8566], 'south korea': [127.7669, 35.9078], 'korea': [127.7669, 35.9078], 'seoul': [126.9780, 37.5665] }; const getCoords = (name) => { const lowerName = (name || '').toLowerCase().trim(); if (countryCoords[lowerName]) return countryCoords[lowerName]; const s = servers.find(sv => (sv.countryName || '').toLowerCase() === lowerName || (sv.country || '').toLowerCase() === lowerName || (sv.city || '').toLowerCase() === lowerName ); if (s && s.lng && s.lat) return [shiftLng(s.lng), s.lat]; return null; }; const getShiftedCoords = (name) => { const c = countryCoords[(name || '').toLowerCase().trim()]; if (c) return [shiftLng(c[0]), c[1]]; const raw = getCoords(name); if (raw) return [shiftLng(raw[0]), raw[1]]; return null; }; // Group latency routes by path to handle overlap visually const routeGroups = {}; currentLatencies.forEach(route => { const start = getShiftedCoords(route.source); const end = getShiftedCoords(route.dest); if (start && end) { // Canonical points for grouping (independent of direction) // Sort by longitude then latitude const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); const key = `${pts[0][0].toFixed(4)},${pts[0][1].toFixed(4)}_${pts[1][0].toFixed(4)},${pts[1][1].toFixed(4)}`; if (!routeGroups[key]) routeGroups[key] = []; routeGroups[key].push({ route, start, end }); } }); Object.keys(routeGroups).forEach(key => { const routes = routeGroups[key]; const count = routes.length; routes.forEach((item, i) => { const { route, start, end } = item; // Identify if the route is in the "canonical" direction const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); const isForward = (start === pts[0]); // Calculate curveness: ensure all lines are slightly curved let finalCurve = 0; if (count === 1) { finalCurve = 0.15; // Default slight curve for single lines } else { // Spread overlapping lines: 0.15, -0.15, 0.3, -0.3... // This creates an "eye" or "fan" effect where no line is straight const magnitude = 0.15 + Math.floor(i / 2) * 0.15; const spread = (i % 2 === 0) ? magnitude : -magnitude; // Adjust sign based on direction so they occupy unique visual slots finalCurve = isForward ? spread : -spread; } finalSeries.push({ type: 'lines', coordinateSystem: 'geo', zlevel: 2, latencyLine: true, effect: { show: true, period: 4, trailLength: 0.1, color: 'rgba(99, 102, 241, 0.8)', symbol: 'arrow', symbolSize: 6 }, lineStyle: { color: 'rgba(99, 102, 241, 0.3)', width: 2, curveness: finalCurve }, tooltip: { formatter: () => { const latVal = (route.latency !== null && route.latency !== undefined) ? `${route.latency.toFixed(2)} ms` : '测量中...'; return `
${route.source} ↔ ${route.dest}
延时: ${latVal}
`; } }, data: [{ fromName: route.source, toName: route.dest, coords: [start, end] }] }); }); }); } myMap2D.setOption({ series: finalSeries }); // Update footer stats if (dom.globeTotalNodes) dom.globeTotalNodes.textContent = geoData.length; if (dom.globeTotalRegions) { const regions = new Set(geoData.map(d => d.country)).size; dom.globeTotalRegions.textContent = regions; } } // ---- 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 updateMap2D(data.servers || []); // Flash animation if (previousMetrics) { // No flash on update } previousMetrics = data; } 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.ok) throw new Error('Fetch failed'); const data = await response.json(); 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) }, { 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: '' } ]; // 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; } 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.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); } } 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() { try { const response = await fetch('/api/metrics/network-history'); 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: '上行+下行' }; dom.p95LabelText.textContent = types[networkChart.p95Type] || '上行'; } networkChart.draw(); } } // Apply to UI applySiteSettings(settings); // 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 (settings.title) { dom.logoText.textContent = settings.title; } // Logo Icon if (settings.logo_url) { dom.logoIconContainer.innerHTML = `Logo`; } else { // Restore default SVG dom.logoIconContainer.innerHTML = ` `; } // 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: '上行+下行' }; dom.p95LabelText.textContent = types[settings.p95_type] || '上行'; } } networkChart.draw(); } } } async function saveSiteSettings() { if (!user) { showSiteMessage('请先登录后操作', 'error'); openLoginModal(); return; } const settings = { page_name: dom.pageNameInput.value.trim(), title: dom.siteTitleInput.value.trim(), logo_url: dom.logoUrlInput.value.trim(), default_theme: dom.defaultThemeInput.value, show_95_bandwidth: dom.show95BandwidthInput.value === "1" ? 1 : 0, p95_type: dom.p95TypeSelect.value }; 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'); applySiteSettings(settings); } else { const err = await response.json(); showSiteMessage(`保存失败: ${err.error}`, 'error'); if (response.status === 401) openLoginModal(); } } catch (err) { showSiteMessage(`保存失败: ${err.message}`, 'error'); } finally { dom.btnSaveSiteSettings.disabled = false; dom.btnSaveSiteSettings.textContent = '保存基础设置'; } } // ---- Latency Routes ---- async function loadLatencyRoutes() { try { const response = await fetch('/api/latency-routes'); 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'); 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); } 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(''); } // ---- 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 ---- 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; } dom.btnAdd.textContent = '添加中...'; dom.btnAdd.disabled = true; try { const response = await fetch('/api/sources', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, url, description, is_server_source, type }) }); if (response.ok) { showMessage('数据源添加成功', 'success'); 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'); 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(); })();