Files
PromdataPanel/public/js/app.js
2026-04-05 17:30:44 +08:00

1492 lines
52 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'),
totalBandwidth: document.getElementById('totalBandwidth'),
bandwidthDetail: document.getElementById('bandwidthDetail'),
traffic24hRx: document.getElementById('traffic24hRx'),
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'),
sourceDesc: document.getElementById('sourceDesc'),
btnTest: document.getElementById('btnTest'),
btnAdd: document.getElementById('btnAdd'),
formMessage: document.getElementById('formMessage'),
sourceItems: document.getElementById('sourceItems'),
// Site Settings
modalTabs: document.querySelectorAll('.modal-tab'),
tabContents: document.querySelectorAll('.tab-content'),
pageNameInput: document.getElementById('pageNameInput'),
siteTitleInput: document.getElementById('siteTitleInput'),
logoUrlInput: document.getElementById('logoUrlInput'),
btnSaveSiteSettings: document.getElementById('btnSaveSiteSettings'),
siteSettingsMessage: document.getElementById('siteSettingsMessage'),
logoText: document.getElementById('logoText'),
logoIconContainer: document.getElementById('logoIconContainer'),
defaultThemeInput: document.getElementById('defaultThemeInput'),
show95BandwidthInput: document.getElementById('show95BandwidthInput'),
// Auth & Theme elements
themeToggle: document.getElementById('themeToggle'),
sunIcon: document.querySelector('.sun-icon'),
moonIcon: document.querySelector('.moon-icon'),
userSection: document.getElementById('userSection'),
btnLogin: document.getElementById('btnLogin'),
loginModal: document.getElementById('loginModalOverlay'),
closeLoginModal: document.getElementById('closeLoginModal'),
loginForm: document.getElementById('loginForm'),
loginError: document.getElementById('loginError'),
footerTime: document.getElementById('footerTime'),
legendP95: document.getElementById('legendP95'),
// 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')
};
// ---- State ----
let previousMetrics = null;
let networkChart = null;
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;
// 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;
// ---- Initialize ----
function init() {
// Resource Gauges Time
updateGaugesTime();
setInterval(updateGaugesTime, 1000);
// Initial theme check (localStorage handled after site settings load to ensure priority)
// Network chart
networkChart = new AreaChart(dom.networkCanvas);
// Initial map
initMap2D();
// Event listeners
dom.btnSettings.addEventListener('click', openSettings);
dom.modalClose.addEventListener('click', closeSettings);
dom.settingsModal.addEventListener('click', (e) => {
if (e.target === dom.settingsModal) closeSettings();
});
dom.btnTest.addEventListener('click', testConnection);
dom.btnAdd.addEventListener('click', addSource);
// Auth & Theme listeners
dom.themeToggle.addEventListener('change', toggleTheme);
dom.btnLogin.addEventListener('click', openLoginModal);
dom.closeLoginModal.addEventListener('click', closeLoginModal);
dom.loginForm.addEventListener('submit', handleLogin);
dom.loginModal.addEventListener('click', (e) => {
if (e.target === dom.loginModal) closeLoginModal();
});
// Tab switching
dom.modalTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.getAttribute('data-tab');
switchTab(targetTab);
});
});
// Site settings
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
// Auth password change
if (dom.btnChangePassword) {
dom.btnChangePassword.addEventListener('click', saveChangePassword);
}
// Keyboard shortcut
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettings();
closeLoginModal();
closeServerDetail();
}
});
// 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();
});
}
// 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);
}
// Check auth status
checkAuthStatus();
// Start data fetching
fetchMetrics();
fetchNetworkHistory();
// 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 || '';
dom.siteTitleInput.value = window.SITE_SETTINGS.title || '';
dom.logoUrlInput.value = window.SITE_SETTINGS.logo_url || '';
dom.defaultThemeInput.value = window.SITE_SETTINGS.default_theme || 'dark';
dom.show95BandwidthInput.value = window.SITE_SETTINGS.show_95_bandwidth ? "1" : "0";
}
loadSiteSettings();
setInterval(fetchMetrics, REFRESH_INTERVAL);
setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL);
}
// ---- Theme Switching ----
function toggleTheme() {
const theme = dom.themeToggle.checked ? 'light' : 'dark';
document.documentElement.classList.toggle('light-theme', theme === 'light');
localStorage.setItem('theme', theme);
updateThemeIcons(theme);
updateMap2DTheme(theme);
}
function applyTheme(theme) {
let actualTheme = theme;
if (theme === 'auto') {
actualTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
const isLight = actualTheme === 'light';
dom.themeToggle.checked = isLight;
document.documentElement.classList.toggle('light-theme', isLight);
updateThemeIcons(actualTheme);
updateMap2DTheme(actualTheme);
}
function updateThemeIcons(theme) {
// Icons are in the slider, we adjust their opacity to show which one is active
if (theme === 'light') {
dom.sunIcon.style.opacity = '0.3';
dom.moonIcon.style.opacity = '1';
} else {
dom.sunIcon.style.opacity = '1';
dom.moonIcon.style.opacity = '0.3';
}
}
// ---- Auth Logic ----
async function checkAuthStatus() {
try {
const resp = await fetch('/api/auth/status');
const data = await resp.json();
if (data.authenticated) {
updateUserUI(data.username);
} else {
updateUserUI(null);
}
} catch (err) {
updateUserUI(null);
}
}
function updateUserUI(username) {
if (username) {
user = username;
dom.btnSettings.style.display = 'flex';
dom.userSection.innerHTML = `
<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);
}
}
// ---- 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();
echarts.registerMap('world', worldJSON);
myMap2D = echarts.init(dom.globeContainer);
const isLight = document.documentElement.classList.contains('light-theme');
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,
emphasis: {
label: { show: false },
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
},
itemStyle: {
areaColor: isLight ? '#eef0f5' : '#1a1d2d',
borderColor: isLight ? '#cbd5e1' : '#2d334d',
borderWidth: 1
},
select: {
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
}
},
series: [{
type: 'effectScatter',
coordinateSystem: 'geo',
geoIndex: 0,
showEffectOn: 'render',
rippleEffect: { brushType: 'stroke', scale: 4, period: 4 },
symbolSize: 6,
itemStyle: {
color: '#06b6d4',
shadowBlur: 10,
shadowColor: '#06b6d4'
},
data: []
}]
};
myMap2D.setOption(option);
window.addEventListener('resize', () => myMap2D.resize());
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 ? '#eef0f5' : '#1a1d2d',
borderColor: isLight ? '#cbd5e1' : '#2d334d'
},
emphasis: {
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
}
}
});
}
function updateMap2D(servers) {
if (!myMap2D) return;
const geoData = servers
.filter(s => s.lat && s.lng)
.map(s => ({
name: s.job,
value: [s.lng, s.lat],
job: s.job,
city: s.city,
country: s.country,
countryName: s.countryName,
netRx: s.netRx,
netTx: s.netTx
}));
myMap2D.setOption({
series: [{
coordinateSystem: 'geo',
geoIndex: 0,
data: geoData
}]
});
// 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.totalBandwidth.textContent = formatBandwidth(data.network.total || 0);
dom.bandwidthDetail.textContent = `${formatBandwidth(data.network.rx)}${formatBandwidth(data.network.tx)}`;
// 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) {
[dom.cpuPercent, dom.memPercent, dom.diskPercent, dom.totalBandwidth].forEach(el => {
el.classList.remove('value-update');
void el.offsetWidth; // Force reflow
el.classList.add('value-update');
});
}
previousMetrics = data;
}
function renderFilteredServers() {
let filtered = allServersData;
if (currentSourceFilter !== 'all') {
filtered = allServersData.filter(s => s.source === currentSourceFilter);
}
// 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.memTotal > 0 ? (a.memUsed / a.memTotal) : 0;
valB = b.memTotal > 0 ? (b.memUsed / b.memTotal) : 0;
break;
case 'disk':
valA = a.diskTotal > 0 ? (a.diskUsed / a.diskTotal) : 0;
valB = b.diskTotal > 0 ? (b.diskUsed / b.diskTotal) : 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');
// 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.memTotal > 0 ? (server.memUsed / server.memTotal * 100) : 0;
const diskPct = server.diskTotal > 0 ? (server.diskUsed / server.diskTotal * 100) : 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) {
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}`;
// 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)}%)</span>
</div>
`;
// Render partitions list if any
if (data.partitions && data.partitions.length > 0) {
dom.detailPartitionsContainer.style.display = 'block';
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
// Remove old listener if any and add new toggle listener
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';
}
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) }
];
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">
<div class="chart-controls">
<div class="time-range-group">
<div class="time-range-selector">
<button class="time-range-btn 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" 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>
</div>
</div>
`).join('');
}
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
loadMetricHistory(metricKey, '1h');
}
};
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';
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 === 'cpuBusy' && data.series) {
// Simplify: ONLY show total busy CPU usage (everything except idle)
// Since it's a percentage, 100 - idle is the total busy percentage
data.values = data.series.idle.map(idleVal => Math.max(0, 100 - idleVal));
data.series = null; // This tells MetricChart to draw a single line instead of stacked area
}
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();
}
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;
if (dom.legendP95) {
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
}
networkChart.draw();
}
}
// Apply to UI
applySiteSettings(settings);
// Handle Theme Priority: localStorage > Site Default
const savedTheme = localStorage.getItem('theme');
const themeToApply = savedTheme || settings.default_theme || 'dark';
applyTheme(themeToApply);
// Listen for system theme changes if set to auto
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
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');
}
});
} catch (err) {
console.error('Error loading site settings:', err);
}
}
function applySiteSettings(settings) {
if (settings.page_name) {
document.title = settings.page_name;
}
if (settings.title) {
dom.logoText.textContent = settings.title;
}
// Logo Icon
if (settings.logo_url) {
dom.logoIconContainer.innerHTML = `<img src="${escapeHtml(settings.logo_url)}" 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>
`;
}
// P95 setting
if (settings.show_95_bandwidth !== undefined) {
if (networkChart) {
networkChart.showP95 = !!settings.show_95_bandwidth;
if (dom.legendP95) {
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
}
networkChart.draw();
}
}
}
async function saveSiteSettings() {
if (!user) {
showSiteMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
const settings = {
page_name: dom.pageNameInput.value.trim(),
title: dom.siteTitleInput.value.trim(),
logo_url: dom.logoUrlInput.value.trim(),
default_theme: dom.defaultThemeInput.value,
show_95_bandwidth: dom.show95BandwidthInput.value === "1" ? 1 : 0
};
dom.btnSaveSiteSettings.disabled = true;
dom.btnSaveSiteSettings.textContent = '保存中...';
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
showSiteMessage('设置保存成功', 'success');
applySiteSettings(settings);
} else {
const err = await response.json();
showSiteMessage(`保存失败: ${err.error}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
showSiteMessage(`保存失败: ${err.message}`, 'error');
} finally {
dom.btnSaveSiteSettings.disabled = false;
dom.btnSaveSiteSettings.textContent = '保存设置';
}
}
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) return;
const current = dom.sourceFilter.value;
let html = '<option value="all">所有数据源</option>';
sources.forEach(source => {
html += `<option value="${escapeHtml(source.name)}">${escapeHtml(source.name)}</option>`;
});
dom.sourceFilter.innerHTML = html;
if (sources.some(s => s.name === current)) {
dom.sourceFilter.value = current;
} else {
dom.sourceFilter.value = 'all';
currentSourceFilter = 'all';
}
}
async function loadSources() {
try {
const response = await fetch('/api/sources');
const sources = await response.json();
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${sources.length} 数据源)`;
updateSourceFilterOptions(sources);
renderSources(sources);
} 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>
</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-delete" 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;
}
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 })
});
const data = await response.json();
if (data.status === 'ok') {
showMessage(`连接成功Prometheus 版本: ${data.version}`, 'success');
} else {
if (response.status === 401) {
showMessage('请先登录', 'error');
openLoginModal();
} else {
showMessage(`连接失败: ${data.message || data.error}`, 'error');
}
}
} catch (err) {
showMessage(`连接失败: ${err.message}`, 'error');
} finally {
dom.btnTest.textContent = '测试连接';
dom.btnTest.disabled = false;
}
}
// ---- Add Source ----
async function addSource() {
if (!user) {
showMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
const name = dom.sourceName.value.trim();
const url = dom.sourceUrl.value.trim();
const description = dom.sourceDesc.value.trim();
if (!name || !url) {
showMessage('请填写名称和URL', 'error');
return;
}
dom.btnAdd.textContent = '添加中...';
dom.btnAdd.disabled = true;
try {
const response = await fetch('/api/sources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, url, description })
});
if (response.ok) {
showMessage('数据源添加成功', 'success');
dom.sourceName.value = '';
dom.sourceUrl.value = '';
dom.sourceDesc.value = '';
loadSources();
fetchMetrics();
fetchNetworkHistory();
} else {
const err = await response.json();
showMessage(`添加失败: ${err.error}`, 'error');
if (response.status === 401) openLoginModal();
}
} catch (err) {
showMessage(`添加失败: ${err.message}`, 'error');
} finally {
dom.btnAdd.textContent = '添加';
dom.btnAdd.disabled = false;
}
}
// ---- Delete Source ----
window.deleteSource = async function (id) {
if (!user) {
showMessage('请先登录后操作', 'error');
openLoginModal();
return;
}
if (!confirm('确定要删除这个数据源吗?')) return;
try {
const response = await fetch(`/api/sources/${id}`, { method: 'DELETE' });
if (response.ok) {
loadSources();
fetchMetrics();
fetchNetworkHistory();
} else {
if (response.status === 401) openLoginModal();
}
} catch (err) {
console.error('Error deleting source:', err);
}
};
// ---- Messages ----
function showMessage(text, type) {
dom.formMessage.textContent = text;
dom.formMessage.className = `form-message ${type}`;
setTimeout(hideMessage, 5000);
}
function hideMessage() {
dom.formMessage.className = 'form-message';
}
// ---- Escape HTML ----
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// ---- Load source count on page load ----
async function loadSourceCount() {
try {
const response = await fetch('/api/sources');
const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : [];
if (dom.totalServersLabel) dom.totalServersLabel.textContent = `服务器总数 (${sourcesArray.length} 数据源)`;
updateSourceFilterOptions(sourcesArray);
} catch (err) {
// ignore
}
}
// ---- Start ----
loadSourceCount();
init();
})();