/** * 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'), sourceFilter: document.getElementById('sourceFilter') }; // ---- 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'; // ---- 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; 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 = `
${escapeHtml(username)}
`; document.getElementById('btnLogout').addEventListener('click', handleLogout); } else { user = null; dom.btnSettings.style.display = 'none'; dom.userSection.innerHTML = ``; 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.totalServers; dom.serverCountText.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); } updateServerTable(filtered); } // ---- Server Table ---- function updateServerTable(servers) { if (!servers || servers.length === 0) { dom.serverTableBody.innerHTML = ` 暂无数据 - 请先配置 Prometheus 数据源 `; 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 `
${escapeHtml(server.job)}
${escapeHtml(server.source)}
${formatPercent(server.cpuPercent)}
${formatPercent(memPct)}
${formatPercent(diskPct)}
${formatBandwidth(server.netRx)} ${formatBandwidth(server.netTx)} `; }).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 = `
${formatPercent(data.cpuBusy)}
Sys: ${data.cpuSystem.toFixed(1)}% User: ${data.cpuUser.toFixed(1)}% Wait: ${data.cpuIowait.toFixed(1)}%
`; const metrics = [ { key: 'cpuBusy', label: 'CPU 使用率 (Busy Breakdown)', 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 => `
${m.label} ${m.value}
`).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(); 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 = `Logo`; } else { // Restore default SVG dom.logoIconContainer.innerHTML = ` `; } } 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 = ''; sources.forEach(source => { html += ``; }); 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(); dom.sourceCount.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 = '
暂无数据源
'; return; } dom.sourceItems.innerHTML = sources.map(source => `
${escapeHtml(source.name)} ${source.status === 'online' ? '在线' : '离线'}
${escapeHtml(source.url)}
${source.description ? `
${escapeHtml(source.description)}
` : ''}
`).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 : []; dom.sourceCount.textContent = `${sourcesArray.length} 个数据源`; updateSourceFilterOptions(sourcesArray); } catch (err) { // ignore } } // ---- Start ---- loadSourceCount(); init(); })();