935 lines
33 KiB
JavaScript
935 lines
33 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
|
||
serverCountText: document.getElementById('serverCountText'),
|
||
sourceCount: document.getElementById('sourceCount'),
|
||
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')
|
||
};
|
||
|
||
// ---- State ----
|
||
let previousMetrics = null;
|
||
let networkChart = null;
|
||
let user = null; // Currently logged in user
|
||
let currentServerDetail = { instance: null, job: null, source: null, charts: {} };
|
||
|
||
// ---- 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();
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
updateDashboard(data);
|
||
} catch (err) {
|
||
console.error('Error fetching metrics:', err);
|
||
}
|
||
}
|
||
|
||
// ---- Update Dashboard ----
|
||
function updateDashboard(data) {
|
||
// Server count
|
||
dom.totalServers.textContent = data.totalServers;
|
||
dom.serverCountText.textContent = `${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
|
||
updateServerTable(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;
|
||
}
|
||
|
||
// ---- Server Table ----
|
||
function updateServerTable(servers) {
|
||
if (!servers || servers.length === 0) {
|
||
dom.serverTableBody.innerHTML = `
|
||
<tr class="empty-row">
|
||
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Sort servers: online first, then by cpu usage
|
||
servers.sort((a, b) => {
|
||
if (a.up !== b.up) return b.up ? 1 : -1;
|
||
return b.cpuPercent - a.cpuPercent;
|
||
});
|
||
|
||
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 metrics = [
|
||
{ key: 'cpuBusy', label: 'CPU 忙碌率 (Busy)', value: formatPercent(data.cpuBusy) },
|
||
{ 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) }
|
||
];
|
||
|
||
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">
|
||
<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';
|
||
|
||
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();
|
||
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';
|
||
}
|
||
|
||
async function loadSources() {
|
||
try {
|
||
const response = await fetch('/api/sources');
|
||
const sources = await response.json();
|
||
dom.sourceCount.textContent = `${sources.length} 个数据源`;
|
||
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();
|
||
dom.sourceCount.textContent = `${Array.isArray(sources) ? sources.length : 0} 个数据源`;
|
||
} catch (err) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// ---- Start ----
|
||
loadSourceCount();
|
||
init();
|
||
})();
|