Files
PromdataPanel/public/js/app.js
2026-04-05 01:06:08 +08:00

1048 lines
37 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'),
// 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'),
sourceFilter: document.getElementById('sourceFilter'),
pageSizeSelect: document.getElementById('pageSizeSelect'),
paginationControls: document.getElementById('paginationControls')
};
// ---- 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;
// ---- 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);
// 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);
// 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) => {
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);
}
}
});
// 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();
});
}
// 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';
}
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);
}
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);
}
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);
}
}
// ---- 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();
// 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 alphabetically by name (job)
filtered.sort((a, b) => {
if (a.up !== b.up) return a.up ? -1 : 1;
const nameA = a.job || '';
const nameB = b.job || '';
return nameA.localeCompare(nameB);
});
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();
};
// ---- 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>
`;
const metrics = [
{ key: 'cpuBusy', label: 'CPU 使用率', value: cpuValueHtml },
{ key: 'sysLoad', label: '系统负载 (Load)', value: data.sysLoad.toFixed(1) + '%' },
{ 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();
}
// ---- 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 || '';
dom.siteTitleInput.value = settings.title || '';
dom.logoUrlInput.value = settings.logo_url || '';
dom.defaultThemeInput.value = settings.default_theme || 'dark';
// 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>
`;
}
}
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
};
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';
}
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();
})();