Files
PromdataPanel/public/js/app.js
2026-04-06 18:46:51 +08:00

2336 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Main Application - Data Visualization Display Wall
*/
(function () {
'use strict';
// ---- Config ----
const REFRESH_INTERVAL = 5000; // 5 seconds
const NETWORK_HISTORY_INTERVAL = 60000; // 1 minute
// ---- DOM Elements ----
const dom = {
clock: document.getElementById('clock'), // May be null if removed from UI
totalServersLabel: document.getElementById('totalServersLabel'),
totalServers: document.getElementById('totalServers'),
cpuPercent: document.getElementById('cpuPercent'),
cpuDetail: document.getElementById('cpuDetail'),
memPercent: document.getElementById('memPercent'),
memDetail: document.getElementById('memDetail'),
diskPercent: document.getElementById('diskPercent'),
diskDetail: document.getElementById('diskDetail'),
totalBandwidthTx: document.getElementById('totalBandwidthTx'),
totalBandwidthRx: document.getElementById('totalBandwidthRx'),
traffic24hRx: document.getElementById('traffic24hRx'),
serverSearchFilter: document.getElementById('serverSearchFilter'),
traffic24hTx: document.getElementById('traffic24hTx'),
traffic24hTotal: document.getElementById('traffic24hTotal'),
trafficP95: document.getElementById('trafficP95'),
networkCanvas: document.getElementById('networkCanvas'),
serverTableBody: document.getElementById('serverTableBody'),
btnSettings: document.getElementById('btnSettings'),
settingsModal: document.getElementById('settingsModal'),
modalClose: document.getElementById('modalClose'),
sourceName: document.getElementById('sourceName'),
sourceUrl: document.getElementById('sourceUrl'),
sourceType: document.getElementById('sourceType'),
sourceDesc: document.getElementById('sourceDesc'),
btnTest: document.getElementById('btnTest'),
btnAdd: document.getElementById('btnAdd'),
isServerSource: document.getElementById('isServerSource'),
formMessage: document.getElementById('formMessage'),
sourceItems: document.getElementById('sourceItems'),
serverSourceOption: document.getElementById('serverSourceOption'),
faviconUrlInput: document.getElementById('faviconUrlInput'),
logoUrlDarkInput: document.getElementById('logoUrlDarkInput'),
showPageNameInput: document.getElementById('showPageNameInput'),
// 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'),
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'),
// 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
// 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 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 = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M10 14l-7 7" />
</svg>
`;
// Resize ECharts immediately (prevents flash)
if (myMap2D) myMap2D.resize();
// FLIP: capture expanded position
const endRect = dom.globeCard.getBoundingClientRect();
const scaleX = savedGlobeRect.width / endRect.width;
const scaleY = savedGlobeRect.height / endRect.height;
const dx = savedGlobeRect.left - endRect.left;
const dy = savedGlobeRect.top - endRect.top;
// Animate using Web Animations API (bypasses all CSS conflicts)
const anim = dom.globeCard.animate([
{
transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`,
boxShadow: '0 0 0 0 transparent, 0 0 0 0 transparent',
offset: 0
},
{
transform: 'translate(0, 0) scale(1)',
boxShadow: '0 0 80px rgba(0,0,0,0.8), 0 0 0 100vh rgba(0,0,0,0.65)',
offset: 1
}
], {
duration: 600,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'none'
});
anim.onfinish = () => {
globeAnimating = false;
};
}
function collapseGlobe() {
if (!dom.globeCard.classList.contains('expanded') || globeAnimating) return;
globeAnimating = true;
const resetState = () => {
dom.globeCard.classList.remove('expanded', 'globe-collapsing');
dom.btnExpandGlobe.classList.remove('active');
dom.btnExpandGlobe.title = '放大显示';
dom.btnExpandGlobe.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
`;
globeAnimating = false;
if (myMap2D) requestAnimationFrame(() => myMap2D.resize());
};
if (!savedGlobeRect) {
resetState();
return;
}
dom.globeCard.classList.add('globe-collapsing');
// FLIP: compute target transform to original position
const expandedRect = dom.globeCard.getBoundingClientRect();
const scaleX = savedGlobeRect.width / expandedRect.width;
const scaleY = savedGlobeRect.height / expandedRect.height;
const dx = savedGlobeRect.left - expandedRect.left;
const dy = savedGlobeRect.top - expandedRect.top;
// Animate from current expanded state to original rect
const anim = dom.globeCard.animate([
{
transform: 'translate(0, 0) scale(1)',
boxShadow: '0 0 80px rgba(0,0,0,0.8), 0 0 0 100vh rgba(0,0,0,0.65)',
offset: 0
},
{
transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`,
boxShadow: '0 0 0 0 transparent, 0 0 0 0 transparent',
offset: 1
}
], {
duration: 500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' // Hold final frame until we remove class
});
anim.onfinish = () => {
anim.cancel(); // Release the fill-forwards hold
resetState();
};
}
if (dom.btnExpandGlobe) {
dom.btnExpandGlobe.addEventListener('click', () => {
if (dom.globeCard.classList.contains('expanded')) {
collapseGlobe();
} else {
expandGlobe();
}
});
}
// 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';
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 || [];
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);
// 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) {
if (!url) return;
const link = dom.siteFavicon || document.querySelector("link[rel*='icon']");
if (link) {
link.href = url;
}
}
function updateThemeIcons(theme) {
// Icons are in the slider, we adjust their opacity to show which one is active
if (theme === 'light') {
dom.sunIcon.style.opacity = '0.3';
dom.moonIcon.style.opacity = '1';
} else {
dom.sunIcon.style.opacity = '1';
dom.moonIcon.style.opacity = '0.3';
}
}
// ---- Auth Logic ----
async function checkAuthStatus() {
try {
const resp = await fetch('/api/auth/status');
const data = await resp.json();
if (data.authenticated) {
updateUserUI(data.username);
} else {
updateUserUI(null);
}
} catch (err) {
updateUserUI(null);
}
}
function updateUserUI(username) {
if (username) {
user = username;
dom.btnSettings.style.display = 'flex';
dom.userSection.innerHTML = `
<div class="user-info">
<span class="username">${escapeHtml(username)}</span>
<button class="btn btn-logout" id="btnLogout">退出</button>
</div>
`;
document.getElementById('btnLogout').addEventListener('click', handleLogout);
} else {
user = null;
dom.btnSettings.style.display = 'none';
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
document.getElementById('btnLogin').addEventListener('click', openLoginModal);
}
}
function 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 = `<div class="chart-empty">地图库加载失败</div>`;
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 `
<div style="padding: 4px;">
<div style="font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 4px; padding-bottom: 4px;">${escapeHtml(d.job)}</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);">${escapeHtml(d.city || '')}, ${escapeHtml(d.countryName || d.country || '')}</div>
<div style="font-size: 0.75rem; margin-top: 4px;">
<span style="color: var(--accent-cyan);">BW:</span> ↓${formatBandwidth(d.netRx)}${formatBandwidth(d.netTx)}
</div>
</div>
`;
}
},
geo: {
map: 'world',
roam: true,
center: [165, 20], // Centered in Pacific
zoom: 1.1,
aspectScale: 0.85,
emphasis: {
label: { show: false },
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
},
itemStyle: {
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
borderColor: isLight ? '#cbd5e1' : '#2d334d',
borderWidth: 1
},
select: {
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
}
},
series: [{
type: 'scatter',
coordinateSystem: 'geo',
geoIndex: 0,
symbolSize: 5,
itemStyle: {
color: '#06b6d4',
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 `
<div style="padding: 4px;">
<div style="font-weight: 700;">${route.source}${route.dest}</div>
<div style="font-size: 0.75rem; color: var(--accent-indigo); margin-top: 4px;">延时: ${latVal}</div>
</div>
`;
}
},
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 += `<button class="page-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="changePage(${currentPage - 1})">上页</button>`;
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`;
} else if (i === currentPage - 3 || i === currentPage + 3) {
html += `<span style="color: var(--text-muted); padding: 0 4px;">...</span>`;
}
}
// Next button
html += `<button class="page-btn" ${currentPage === totalPages ? 'disabled' : ''} onclick="changePage(${currentPage + 1})">下页</button>`;
dom.paginationControls.innerHTML = html;
}
window.changePage = function (page) {
currentPage = page;
renderFilteredServers();
};
function resetSort() {
currentSort = { column: 'up', direction: 'desc' };
localStorage.removeItem('serverListSort');
// Clear filters
if (dom.serverSearchFilter) {
dom.serverSearchFilter.value = '';
}
if (dom.sourceFilter) {
dom.sourceFilter.value = 'all';
}
currentSourceFilter = 'all';
// Update UI headers
const headers = document.querySelectorAll('.server-table th.sortable');
headers.forEach(th => {
const col = th.getAttribute('data-sort');
if (col === currentSort.column) {
th.classList.add('active');
th.setAttribute('data-dir', currentSort.direction);
} else {
th.classList.remove('active');
th.removeAttribute('data-dir');
}
});
renderFilteredServers();
}
function handleHeaderSort(column) {
if (currentSort.column === column) {
if (currentSort.direction === 'desc') {
currentSort.direction = 'asc';
} else {
// Cycle back to default (Status desc)
resetSort();
return;
}
} else {
currentSort.column = column;
currentSort.direction = 'desc'; // Default to desc for most metrics
}
// Persist sort state
localStorage.setItem('serverListSort', JSON.stringify(currentSort));
// Update UI headers
const headers = document.querySelectorAll('.server-table th.sortable');
headers.forEach(th => {
const col = th.getAttribute('data-sort');
if (col === currentSort.column) {
th.classList.add('active');
th.setAttribute('data-dir', currentSort.direction);
} else {
th.classList.remove('active');
th.removeAttribute('data-dir');
}
});
renderFilteredServers();
}
// ---- Server Table ----
function updateServerTable(servers) {
if (!servers || servers.length === 0) {
dom.serverTableBody.innerHTML = `
<tr class="empty-row">
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr>
`;
return;
}
dom.serverTableBody.innerHTML = servers.map(server => {
const memPct = server.memPercent || 0;
const diskPct = server.diskPercent || 0;
return `
<tr data-instance="${escapeHtml(server.instance)}" data-job="${escapeHtml(server.job)}" data-source="${escapeHtml(server.source)}" style="cursor: pointer;">
<td>
<span class="status-dot ${server.up ? 'status-dot-online' : 'status-dot-offline'}"></span>
</td>
<td>
<div style="color: var(--text-primary); font-weight: 600; font-family: var(--font-sans);">${escapeHtml(server.job)}</div>
</td>
<td>${escapeHtml(server.source)}</td>
<td>
<div class="usage-bar">
<div class="usage-bar-track">
<div class="usage-bar-fill usage-bar-fill-cpu" style="width: ${Math.min(server.cpuPercent, 100)}%"></div>
</div>
<span>${formatPercent(server.cpuPercent)}</span>
</div>
</td>
<td>
<div class="usage-bar">
<div class="usage-bar-track">
<div class="usage-bar-fill usage-bar-fill-mem" style="width: ${Math.min(memPct, 100)}%"></div>
</div>
<span>${formatPercent(memPct)}</span>
</div>
</td>
<td>
<div class="usage-bar">
<div class="usage-bar-track">
<div class="usage-bar-fill usage-bar-fill-disk" style="width: ${Math.min(diskPct, 100)}%"></div>
</div>
<span>${formatPercent(diskPct)}</span>
</div>
</td>
<td>${formatBandwidth(server.netRx)}</td>
<td>${formatBandwidth(server.netTx)}</td>
</tr>
`;
}).join('');
}
// ---- Server Detail ----
async function openServerDetail(instance, job, source) {
// Cleanup old charts if any were still present from a previous open (safety)
if (currentServerDetail.charts) {
Object.values(currentServerDetail.charts).forEach(chart => {
if (chart && chart.destroy) chart.destroy();
});
}
currentServerDetail = { instance, job, source, charts: {} };
dom.serverDetailTitle.textContent = `${job}`;
dom.serverDetailModal.classList.add('active');
// Show loading
dom.detailContainer.style.opacity = '0.3';
dom.detailLoading.style.display = 'block';
dom.detailMetricsList.innerHTML = '';
try {
const url = `/api/metrics/server-details?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}`;
const response = await fetch(url);
if (!response.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 = `
<div style="display: flex; align-items: baseline; gap: 8px;">
<span style="font-weight: 700; font-size: 1.1rem;">${formatPercent(data.cpuBusy)}</span>
<span style="font-size: 0.7rem; color: var(--text-secondary); font-weight: normal;">(IO Wait: ${data.cpuIowait.toFixed(1)}%, Busy Others: ${data.cpuOther.toFixed(1)}%)</span>
</div>
`;
// Define metrics to show
const metrics = [
{ key: 'cpuBusy', label: 'CPU 使用率', value: cpuValueHtml },
{ key: 'memUsedPct', label: '内存使用率 (RAM)', value: 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 => `
<div class="metric-item" id="metric-${m.key}">
<div class="metric-item-header" onclick="toggleMetricExpand('${m.key}')">
<div class="metric-label-group">
<span class="metric-label">${m.label}</span>
<span class="metric-value">${m.value}</span>
</div>
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="metric-item-content" id="metrics-content-${m.key}">
<div class="chart-controls">
<div class="time-range-group">
<div class="time-range-selector">
<button class="time-range-btn ${m.key !== 'networkTrend' ? 'active' : ''}" onclick="loadMetricHistory('${m.key}', '1h', event)">1h</button>
<button class="time-range-btn" onclick="loadMetricHistory('${m.key}', '6h', event)">6h</button>
<button class="time-range-btn" onclick="loadMetricHistory('${m.key}', '12h', event)">12h</button>
<button class="time-range-btn ${m.key === 'networkTrend' ? 'active' : ''}" onclick="loadMetricHistory('${m.key}', '24h', event)">24h</button>
</div>
<div class="custom-range-selector">
<input type="text" class="custom-range-input" id="custom-range-${m.key}" placeholder="相对范围 (如: 2h)" onkeydown="if(event.key==='Enter') loadCustomMetricHistory('${m.key}', event)">
</div>
<div class="absolute-range-selector">
<input type="datetime-local" class="time-input" id="start-time-${m.key}">
<span style="color: var(--text-secondary); font-size: 0.7rem;">至</span>
<input type="datetime-local" class="time-input" id="end-time-${m.key}">
<button class="btn-custom-go" onclick="loadCustomMetricHistory('${m.key}', event)">查询</button>
</div>
</div>
</div>
<div class="detail-chart-wrapper" style="${m.key === 'cpuBusy' ? 'height: 260px;' : ''}">
<canvas id="chart-${m.key}"></canvas>
</div>
${m.key === 'networkTrend' ? `
<div class="chart-footer" id="summary-${m.key}" style="display: none; border-top: 1px solid var(--border-color); margin-top: 10px; padding-top: 15px;">
<div class="traffic-stat">
<span class="traffic-label">24h 接收总量</span>
<span class="traffic-value" id="stat-${m.key}-rx">0 B</span>
</div>
<div class="traffic-stat">
<span class="traffic-label">24h 发送总量</span>
<span class="traffic-value" id="stat-${m.key}-tx">0 B</span>
</div>
<div class="traffic-stat traffic-stat-p95">
<span class="traffic-label">95计费 (上行)</span>
<span class="traffic-value" id="stat-${m.key}-p95">0 B/s</span>
</div>
<div class="traffic-stat traffic-stat-total">
<span class="traffic-label">24h 总流量</span>
<span class="traffic-value" id="stat-${m.key}-total">0 B</span>
</div>
</div>` : ''}
</div>
</div>
`).join('');
// 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 => `
<div class="partition-row">
<div class="partition-info">
<span class="partition-mount">${escapeHtml(p.mountpoint)}</span>
<span class="partition-usage-text">${formatBytes(p.used)} / ${formatBytes(p.size)} (${formatPercent(p.percent)})</span>
</div>
<div class="partition-progress">
<div class="partition-bar" style="width: ${Math.min(p.percent, 100)}%; background-color: ${getUsageColor(p.percent)};"></div>
</div>
</div>
`).join('');
} else {
dom.detailPartitionsContainer.style.display = 'none';
}
}
window.toggleMetricExpand = async function (metricKey) {
const el = document.getElementById(`metric-${metricKey}`);
const wasActive = el.classList.contains('active');
// Close all others
document.querySelectorAll('.metric-item').forEach(item => item.classList.remove('active'));
if (!wasActive) {
el.classList.add('active');
// Initial load
const defaultRange = metricKey === 'networkTrend' ? '24h' : '1h';
loadMetricHistory(metricKey, defaultRange);
}
};
window.loadMetricHistory = async function (metricKey, range, event, start = null, end = null) {
if (event) {
event.stopPropagation();
const group = event.target.closest('.time-range-group');
if (group) {
group.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
if (event.target.classList.contains('time-range-btn')) {
event.target.classList.add('active');
}
}
}
const canvas = document.getElementById(`chart-${metricKey}`);
if (!canvas) return;
let chart = currentServerDetail.charts[metricKey];
if (!chart) {
let unit = '';
if (metricKey.includes('Pct') || metricKey === 'cpuBusy') unit = '%';
if (metricKey.startsWith('net')) unit = 'B/s';
if (metricKey === 'sockstatTcpMem') unit = 'B';
if (metricKey === 'networkTrend') {
chart = new AreaChart(canvas);
chart.padding = { top: 15, right: 15, bottom: 35, left: 65 };
} else {
chart = new MetricChart(canvas, unit);
}
currentServerDetail.charts[metricKey] = chart;
}
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();
}
}
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";
// 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;
}
if (logoToUse) {
dom.logoIconContainer.innerHTML = `<img src="${escapeHtml(logoToUse)}" alt="Logo" class="logo-icon-img">`;
} else {
// Restore default SVG
dom.logoIconContainer.innerHTML = `
<svg class="logo-icon" id="logoSvg" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="28" height="28" rx="8" stroke="url(#logoGrad)" stroke-width="2.5"/>
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="12" cy="14" r="2" fill="url(#logoGrad)"/>
<circle cx="20" cy="10" r="2" fill="url(#logoGrad)"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
</svg>
`;
}
// 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: '上行+下行' };
dom.p95LabelText.textContent = types[settings.p95_type] || '上行';
}
}
networkChart.draw();
}
}
// Default Theme
if (settings.default_theme) {
if (dom.defaultThemeInput) dom.defaultThemeInput.value = settings.default_theme;
}
// Filing info
let hasFilings = false;
if (dom.psFilingDisplay) {
if (settings.ps_filing) {
if (dom.psFilingText) dom.psFilingText.textContent = settings.ps_filing;
dom.psFilingDisplay.style.display = 'inline-block';
hasFilings = true;
} else {
dom.psFilingDisplay.style.display = 'none';
}
}
if (dom.icpFilingDisplay) {
if (settings.icp_filing) {
dom.icpFilingDisplay.textContent = settings.icp_filing;
dom.icpFilingDisplay.style.display = 'inline-block';
hasFilings = true;
} else {
dom.icpFilingDisplay.style.display = 'none';
}
}
const footerContent = document.querySelector('.footer-content');
if (footerContent) {
footerContent.classList.toggle('only-copyright', !hasFilings);
}
}
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,
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() : ''
};
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);
} 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');
const routes = await response.json();
renderLatencyRoutes(routes);
} catch (err) {
console.error('Error loading latency routes:', err);
}
}
function renderLatencyRoutes(routes) {
if (routes.length === 0) {
dom.latencyRoutesList.innerHTML = `<div class="route-empty" style="text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; background: rgba(0,0,0,0.1); border-radius: 8px;">暂无线路</div>`;
return;
}
dom.latencyRoutesList.innerHTML = routes.map(route => `
<div class="latency-route-item" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-color); border-radius: 8px;">
<div class="route-info" style="display: flex; flex-direction: column; gap: 4px;">
<div style="font-weight: 600; font-size: 0.88rem; color: var(--text-primary);">
${escapeHtml(route.latency_source)}${escapeHtml(route.latency_dest)}
</div>
<div style="font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 10px;">
<span>数据源: ${escapeHtml(route.source_name || '已删除')}</span>
<span>目标: ${escapeHtml(route.latency_target)}</span>
</div>
</div>
<div class="route-actions" style="display: flex; gap: 8px;">
<button class="btn btn-test" onclick="editRoute(${route.id}, ${route.source_id}, '${escapeHtml(route.latency_source)}', '${escapeHtml(route.latency_dest)}', '${escapeHtml(route.latency_target)}')" style="padding: 4px 10px; font-size: 0.72rem;">编辑</button>
<button class="btn btn-delete" onclick="deleteLatencyRoute(${route.id})" style="padding: 4px 10px; font-size: 0.72rem;">删除</button>
</div>
</div>
`).join('');
}
window.editRoute = function (id, source_id, source, dest, target) {
editingRouteId = id;
dom.routeSourceSelect.value = source_id;
dom.routeSourceInput.value = source;
dom.routeDestInput.value = dest;
dom.routeTargetInput.value = target;
dom.btnAddRoute.textContent = '保存修改';
dom.btnCancelEditRoute.style.display = 'block';
// Select the tab just in case (though it's already there)
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
if (tab) tab.click();
};
function cancelEditRoute() {
editingRouteId = null;
dom.routeSourceSelect.value = "";
dom.routeSourceInput.value = "";
dom.routeDestInput.value = "";
dom.routeTargetInput.value = "";
dom.btnAddRoute.textContent = '添加线路';
dom.btnCancelEditRoute.style.display = 'none';
}
async function addLatencyRoute() {
if (!user) {
showSiteMessage('请先登录及进行身份验证', 'error');
openLoginModal();
return;
}
const source_id = dom.routeSourceSelect.value;
const latency_source = dom.routeSourceInput.value.trim();
const latency_dest = dom.routeDestInput.value.trim();
const latency_target = dom.routeTargetInput.value.trim();
if (!source_id || !latency_source || !latency_dest || !latency_target) {
showSiteMessage('请填写完整的线路信息', 'error');
return;
}
const url = editingRouteId ? `/api/latency-routes/${editingRouteId}` : '/api/latency-routes';
const method = editingRouteId ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: parseInt(source_id), latency_source, latency_dest, latency_target })
});
if (response.ok) {
showSiteMessage(editingRouteId ? '线路更新成功' : '线路添加成功', 'success');
cancelEditRoute();
loadLatencyRoutes();
fetchLatency();
} else {
const err = await response.json();
showSiteMessage(`操作失败: ${err.error}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
console.error('Error adding latency route:', err);
}
}
window.deleteLatencyRoute = async function (id) {
if (!user) {
showSiteMessage('请登录后再操作', 'error');
openLoginModal();
return;
}
if (!confirm('确定要删除这条延迟线路吗?')) return;
try {
const response = await fetch(`/api/latency-routes/${id}`, { method: 'DELETE' });
if (response.ok) {
loadLatencyRoutes();
fetchLatency();
} else {
if (response.status === 401) openLoginModal();
}
} catch (err) {
console.error('Error deleting latency route:', err);
}
};
function showSiteMessage(text, type) {
dom.siteSettingsMessage.textContent = text;
dom.siteSettingsMessage.className = `form-message ${type}`;
setTimeout(hideSiteMessage, 5000);
}
function hideSiteMessage() {
dom.siteSettingsMessage.className = 'form-message';
}
async function saveChangePassword() {
if (!user) {
showChangePasswordMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
const oldPassword = dom.oldPasswordInput.value;
const newPassword = dom.newPasswordInput.value;
const confirmNewPassword = dom.confirmNewPasswordInput.value;
if (!oldPassword || !newPassword || !confirmNewPassword) {
showChangePasswordMessage('请填写所有密码字段', 'error');
return;
}
if (newPassword !== confirmNewPassword) {
showChangePasswordMessage('两次输入的新密码不一致', 'error');
return;
}
if (newPassword.length < 6) {
showChangePasswordMessage('新密码长度至少为 6 位', 'error');
return;
}
dom.btnChangePassword.disabled = true;
dom.btnChangePassword.textContent = '提交中...';
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
oldPassword,
newPassword
})
});
const data = await response.json();
if (response.ok) {
showChangePasswordMessage('密码修改成功', 'success');
dom.oldPasswordInput.value = '';
dom.newPasswordInput.value = '';
dom.confirmNewPasswordInput.value = '';
} else {
showChangePasswordMessage(data.error || '修改失败', 'error');
if (response.status === 401 && data.error === 'Auth required') openLoginModal();
}
} catch (err) {
showChangePasswordMessage(`请求失败: ${err.message}`, 'error');
} finally {
dom.btnChangePassword.disabled = false;
dom.btnChangePassword.textContent = '提交修改';
}
}
function showChangePasswordMessage(text, type) {
dom.changePasswordMessage.textContent = text;
dom.changePasswordMessage.className = `form-message ${type}`;
setTimeout(hideChangePasswordMessage, 5000);
}
function hideChangePasswordMessage() {
dom.changePasswordMessage.className = 'form-message';
}
function updateSourceFilterOptions(sources) {
if (dom.sourceFilter) {
const current = dom.sourceFilter.value;
let html = '<option value="all">所有数据源</option>';
const displaySources = sources.filter(s => s.type !== 'blackbox');
displaySources.forEach(source => {
html += `<option value="${escapeHtml(source.name)}">${escapeHtml(source.name)}</option>`;
});
dom.sourceFilter.innerHTML = html;
if (displaySources.some(s => s.name === current)) {
dom.sourceFilter.value = current;
} else {
dom.sourceFilter.value = 'all';
currentSourceFilter = 'all';
}
}
if (dom.routeSourceSelect) {
const currentVal = dom.routeSourceSelect.value;
const blackboxSources = sources.filter(s => s.type === 'blackbox');
dom.routeSourceSelect.innerHTML = '<option value="">-- 选择数据源 --</option>' +
blackboxSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
dom.routeSourceSelect.value = currentVal;
}
}
async function loadSources() {
try {
const response = await fetch('/api/sources');
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 = '<div class="source-empty">暂无数据源</div>';
return;
}
dom.sourceItems.innerHTML = sources.map(source => `
<div class="source-item" data-id="${source.id}">
<div class="source-item-info">
<div class="source-item-name">
${escapeHtml(source.name)}
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
${source.status === 'online' ? '在线' : '离线'}
</span>
<span class="source-type-badge ${source.is_server_source ? 'type-server' : 'type-other'}" title="${source.is_server_source ? '该数据源用于展示服务器列表和指标' : '该数据源仅用于特定目的(如 Blackbox 延迟),不参与服务器列表统计'}">
${source.type === 'blackbox' ? 'Blackbox' : (source.is_server_source ? '服务器看板' : '独立数据源')}
</span>
</div>
<div class="source-item-url">${escapeHtml(source.url)}</div>
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
</div>
<div class="source-item-actions">
<button class="btn btn-secondary btn-sm" onclick="editSource(${JSON.stringify(source).replace(/"/g, '&quot;')})">编辑</button>
<button class="btn btn-delete btn-sm" onclick="deleteSource(${source.id})">删除</button>
</div>
</div>
`).join('');
}
// ---- 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');
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();
})();