2342 lines
84 KiB
JavaScript
2342 lines
84 KiB
JavaScript
/**
|
||
* 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
|
||
// Use a canonical hash of the route endpoints to deterministically decide the curve direction
|
||
// This helps spread lines across the map even for single routes
|
||
const names = [route.source, route.dest].sort();
|
||
const routeHash = (names[0] + names[1]).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||
const baseSign = (routeHash % 2 === 0 ? 1 : -1);
|
||
|
||
let finalCurve = 0;
|
||
if (count === 1) {
|
||
// Single lines get a decent curve, alternating direction based on hash
|
||
finalCurve = 0.2 * baseSign;
|
||
} else {
|
||
// Multiple lines between same points fan out with increasing magnitude
|
||
const magnitude = 0.2 + Math.floor(i / 2) * 0.15;
|
||
const spread = (i % 2 === 0) ? magnitude : -magnitude;
|
||
// Apply baseSign to ensure the whole group isn't biased to one side
|
||
finalCurve = isForward ? (spread * baseSign) : (-spread * baseSign);
|
||
}
|
||
|
||
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, '"')})">编辑</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();
|
||
})();
|