First Commit

This commit is contained in:
CN-JS-HuiBai
2026-04-04 15:13:32 +08:00
commit e69424dab2
14 changed files with 3927 additions and 0 deletions

1050
public/css/style.css Normal file

File diff suppressed because it is too large Load Diff

310
public/index.html Normal file
View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="多源Prometheus服务器监控展示大屏 - 实时CPU、内存、磁盘、网络统计">
<title>数据可视化展示大屏</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- Animated Background -->
<div class="bg-grid"></div>
<div class="bg-glow bg-glow-1"></div>
<div class="bg-glow bg-glow-2"></div>
<div class="bg-glow bg-glow-3"></div>
<!-- App Container -->
<div id="app">
<!-- Header -->
<header class="header" id="header">
<div class="header-left">
<div class="logo">
<svg class="logo-icon" 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>
<h1 class="logo-text">数据可视化展示大屏</h1>
</div>
<div class="header-meta">
<span class="server-count" id="serverCount">
<span class="dot dot-pulse"></span>
<span id="serverCountText">0 台服务器</span>
</span>
<span class="source-count" id="sourceCount">0 个数据源</span>
</div>
</div>
<div class="header-right">
<div class="clock" id="clock"></div>
<button class="btn-settings" id="btnSettings" title="配置管理">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
</header>
<!-- Main Dashboard -->
<main class="dashboard" id="dashboard">
<!-- Top Stat Cards -->
<section class="stat-cards">
<div class="stat-card stat-card-servers" id="cardServers">
<div class="stat-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2"/>
<rect x="2" y="14" width="20" height="8" rx="2"/>
<circle cx="6" cy="6" r="1" fill="currentColor"/>
<circle cx="6" cy="18" r="1" fill="currentColor"/>
</svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">服务器总数</span>
<span class="stat-card-value" id="totalServers">0</span>
</div>
</div>
<div class="stat-card stat-card-cpu" id="cardCpu">
<div class="stat-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="4" y="4" width="16" height="16" rx="2"/>
<rect x="9" y="9" width="6" height="6"/>
<line x1="9" y1="2" x2="9" y2="4"/><line x1="15" y1="2" x2="15" y2="4"/>
<line x1="9" y1="20" x2="9" y2="22"/><line x1="15" y1="20" x2="15" y2="22"/>
<line x1="2" y1="9" x2="4" y2="9"/><line x1="2" y1="15" x2="4" y2="15"/>
<line x1="20" y1="9" x2="22" y2="9"/><line x1="20" y1="15" x2="22" y2="15"/>
</svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">CPU 使用率</span>
<span class="stat-card-value" id="cpuPercent">0%</span>
<span class="stat-card-sub" id="cpuDetail">0 / 0 核心</span>
</div>
</div>
<div class="stat-card stat-card-mem" id="cardMem">
<div class="stat-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M7 7h4v4H7zM13 7h4v4h-4zM7 13h4v4H7zM13 13h4v4h-4z"/>
</svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">内存使用率</span>
<span class="stat-card-value" id="memPercent">0%</span>
<span class="stat-card-sub" id="memDetail">0 / 0 GB</span>
</div>
</div>
<div class="stat-card stat-card-disk" id="cardDisk">
<div class="stat-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">磁盘使用率</span>
<span class="stat-card-value" id="diskPercent">0%</span>
<span class="stat-card-sub" id="diskDetail">0 / 0 GB</span>
</div>
</div>
<div class="stat-card stat-card-bandwidth" id="cardBandwidth">
<div class="stat-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="stat-card-content">
<span class="stat-card-label">实时总带宽</span>
<span class="stat-card-value" id="totalBandwidth">0 B/s</span>
<span class="stat-card-sub" id="bandwidthDetail">↓ 0 ↑ 0</span>
</div>
</div>
</section>
<!-- Center Charts -->
<section class="charts-section">
<!-- Network Traffic 24h Chart -->
<div class="chart-card chart-card-wide" id="networkChart">
<div class="chart-card-header">
<h2 class="chart-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
网络流量趋势 (24h)
</h2>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot legend-rx"></span>接收 (RX)</span>
<span class="legend-item"><span class="legend-dot legend-tx"></span>发送 (TX)</span>
</div>
</div>
<div class="chart-body">
<canvas id="networkCanvas"></canvas>
</div>
<div class="chart-footer">
<div class="traffic-stat">
<span class="traffic-label">24h 接收总量</span>
<span class="traffic-value" id="traffic24hRx">0 B</span>
</div>
<div class="traffic-stat">
<span class="traffic-label">24h 发送总量</span>
<span class="traffic-value" id="traffic24hTx">0 B</span>
</div>
<div class="traffic-stat traffic-stat-total">
<span class="traffic-label">24h 总流量</span>
<span class="traffic-value" id="traffic24hTotal">0 B</span>
</div>
</div>
</div>
<!-- Resource Gauges -->
<div class="chart-card chart-card-gauges" id="gaugesCard">
<div class="chart-card-header">
<h2 class="chart-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
<path d="M12 20V10M18 20V4M6 20v-4"/>
</svg>
资源使用概览
</h2>
</div>
<div class="gauges-container">
<div class="gauge-wrapper">
<div class="gauge" id="gaugeCpu">
<svg viewBox="0 0 120 120">
<circle class="gauge-bg" cx="60" cy="60" r="52"/>
<circle class="gauge-fill gauge-fill-cpu" cx="60" cy="60" r="52" id="gaugeCpuFill"/>
</svg>
<div class="gauge-center">
<span class="gauge-value" id="gaugeCpuValue">0%</span>
<span class="gauge-label">CPU</span>
</div>
</div>
</div>
<div class="gauge-wrapper">
<div class="gauge" id="gaugeRam">
<svg viewBox="0 0 120 120">
<circle class="gauge-bg" cx="60" cy="60" r="52"/>
<circle class="gauge-fill gauge-fill-ram" cx="60" cy="60" r="52" id="gaugeRamFill"/>
</svg>
<div class="gauge-center">
<span class="gauge-value" id="gaugeRamValue">0%</span>
<span class="gauge-label">RAM</span>
</div>
</div>
</div>
<div class="gauge-wrapper">
<div class="gauge" id="gaugeDisk">
<svg viewBox="0 0 120 120">
<circle class="gauge-bg" cx="60" cy="60" r="52"/>
<circle class="gauge-fill gauge-fill-disk" cx="60" cy="60" r="52" id="gaugeDiskFill"/>
</svg>
<div class="gauge-center">
<span class="gauge-value" id="gaugeDiskValue">0%</span>
<span class="gauge-label">DISK</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Server List -->
<section class="server-list-section" id="serverListSection">
<div class="chart-card">
<div class="chart-card-header">
<h2 class="chart-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
<rect x="2" y="2" width="20" height="8" rx="2"/>
<rect x="2" y="14" width="20" height="8" rx="2"/>
<circle cx="6" cy="6" r="1" fill="currentColor"/>
<circle cx="6" cy="18" r="1" fill="currentColor"/>
</svg>
服务器详情
</h2>
</div>
<div class="server-table-wrap">
<table class="server-table" id="serverTable">
<thead>
<tr>
<th>状态</th>
<th>服务器</th>
<th>数据源</th>
<th>CPU</th>
<th>内存</th>
<th>磁盘</th>
<th>网络 ↓</th>
<th>网络 ↑</th>
</tr>
</thead>
<tbody id="serverTableBody">
<tr class="empty-row">
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>Prometheus 数据源管理</h2>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<!-- Add Source Form -->
<div class="add-source-form" id="addSourceForm">
<h3>添加数据源</h3>
<div class="form-row">
<div class="form-group">
<label for="sourceName">名称</label>
<input type="text" id="sourceName" placeholder="例:生产环境" autocomplete="off">
</div>
<div class="form-group form-group-wide">
<label for="sourceUrl">Prometheus URL</label>
<input type="url" id="sourceUrl" placeholder="http://prometheus.example.com:9090" autocomplete="off">
</div>
</div>
<div class="form-row">
<div class="form-group form-group-wide">
<label for="sourceDesc">描述 (可选)</label>
<input type="text" id="sourceDesc" placeholder="数据源描述" autocomplete="off">
</div>
<div class="form-actions">
<button class="btn btn-test" id="btnTest">测试连接</button>
<button class="btn btn-add" id="btnAdd">添加</button>
</div>
</div>
<div class="form-message" id="formMessage"></div>
</div>
<!-- Source List -->
<div class="source-list" id="sourceList">
<h3>已配置数据源</h3>
<div class="source-items" id="sourceItems">
<div class="source-empty">暂无数据源</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/utils.js"></script>
<script src="/js/chart.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

433
public/js/app.js Normal file
View File

@@ -0,0 +1,433 @@
/**
* 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'),
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'),
networkCanvas: document.getElementById('networkCanvas'),
gaugeCpuFill: document.getElementById('gaugeCpuFill'),
gaugeRamFill: document.getElementById('gaugeRamFill'),
gaugeDiskFill: document.getElementById('gaugeDiskFill'),
gaugeCpuValue: document.getElementById('gaugeCpuValue'),
gaugeRamValue: document.getElementById('gaugeRamValue'),
gaugeDiskValue: document.getElementById('gaugeDiskValue'),
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')
};
// ---- State ----
let previousMetrics = null;
let networkChart = null;
// ---- Initialize ----
function init() {
// Add SVG gradient definitions for gauges
addGaugeSvgDefs();
// Clock
updateClock();
setInterval(updateClock, 1000);
// 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);
// Keyboard shortcut
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeSettings();
});
// Start data fetching
fetchMetrics();
fetchNetworkHistory();
setInterval(fetchMetrics, REFRESH_INTERVAL);
setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL);
}
// ---- Add SVG Gradient Defs ----
function addGaugeSvgDefs() {
const svgs = document.querySelectorAll('.gauge svg');
const gradients = [
{ id: 'gaugeCpuGrad', colors: ['#6366f1', '#818cf8'] },
{ id: 'gaugeRamGrad', colors: ['#06b6d4', '#22d3ee'] },
{ id: 'gaugeDiskGrad', colors: ['#f59e0b', '#fbbf24'] }
];
svgs.forEach((svg, i) => {
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
grad.setAttribute('id', gradients[i].id);
grad.setAttribute('x1', '0%');
grad.setAttribute('y1', '0%');
grad.setAttribute('x2', '100%');
grad.setAttribute('y2', '100%');
gradients[i].colors.forEach((color, ci) => {
const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
stop.setAttribute('offset', ci === 0 ? '0%' : '100%');
stop.setAttribute('stop-color', color);
grad.appendChild(stop);
});
defs.appendChild(grad);
svg.insertBefore(defs, svg.firstChild);
});
}
// ---- Clock ----
function updateClock() {
dom.clock.textContent = formatClock();
}
// ---- 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.totalBandwidth);
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);
// Update gauges
updateGauge(dom.gaugeCpuFill, dom.gaugeCpuValue, cpuPct);
updateGauge(dom.gaugeRamFill, dom.gaugeRamValue, memPct);
updateGauge(dom.gaugeDiskFill, dom.gaugeDiskValue, diskPct);
// 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;
}
// ---- Gauge Update ----
const CIRCUMFERENCE = 2 * Math.PI * 52; // r=52
function updateGauge(fillEl, valueEl, percent) {
const clamped = Math.min(100, Math.max(0, percent));
const offset = CIRCUMFERENCE - (clamped / 100) * CIRCUMFERENCE;
fillEl.style.strokeDashoffset = offset;
valueEl.textContent = formatPercent(clamped);
// Change color based on usage
const color = getUsageColor(clamped);
// We keep gradient but could override for critical
}
// ---- 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>
<td>
<span class="status-dot ${server.up ? 'status-dot-online' : 'status-dot-offline'}"></span>
</td>
<td style="color: var(--text-primary); font-weight: 500;">${escapeHtml(server.instance)}</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('');
}
// ---- Network History ----
async function fetchNetworkHistory() {
try {
const response = await fetch('/api/metrics/network-history');
const data = await response.json();
networkChart.setData(data);
} 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();
}
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 {
showMessage(`连接失败: ${data.message}`, 'error');
}
} catch (err) {
showMessage(`连接失败: ${err.message}`, 'error');
} finally {
dom.btnTest.textContent = '测试连接';
dom.btnTest.disabled = false;
}
}
// ---- Add Source ----
async function addSource() {
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();
// Refresh metrics immediately
fetchMetrics();
fetchNetworkHistory();
} else {
const err = await response.json();
showMessage(`添加失败: ${err.error}`, 'error');
}
} catch (err) {
showMessage(`添加失败: ${err.message}`, 'error');
} finally {
dom.btnAdd.textContent = '添加';
dom.btnAdd.disabled = false;
}
}
// ---- Delete Source ----
window.deleteSource = async function (id) {
if (!confirm('确定要删除这个数据源吗?')) return;
try {
const response = await fetch(`/api/sources/${id}`, { method: 'DELETE' });
if (response.ok) {
loadSources();
fetchMetrics();
fetchNetworkHistory();
}
} 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 = `${sources.length} 个数据源`;
} catch (err) {
// ignore
}
}
// ---- Start ----
loadSourceCount();
init();
})();

176
public/js/chart.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* Custom Canvas Chart Renderer
* Lightweight, no-dependency chart for the network traffic area chart
*/
class AreaChart {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.data = { timestamps: [], rx: [], tx: [] };
this.animProgress = 0;
this.animFrame = null;
this.dpr = window.devicePixelRatio || 1;
this.padding = { top: 20, right: 16, bottom: 32, left: 56 };
this._resize = this.resize.bind(this);
window.addEventListener('resize', this._resize);
this.resize();
}
resize() {
const rect = this.canvas.parentElement.getBoundingClientRect();
this.width = rect.width;
this.height = rect.height;
this.canvas.width = this.width * this.dpr;
this.canvas.height = this.height * this.dpr;
this.canvas.style.width = this.width + 'px';
this.canvas.style.height = this.height + 'px';
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
this.draw();
}
setData(data) {
this.data = data;
this.animProgress = 0;
this.animate();
}
animate() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
const start = performance.now();
const duration = 800;
const step = (now) => {
const elapsed = now - start;
this.animProgress = Math.min(elapsed / duration, 1);
// Ease out cubic
this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
this.draw();
if (this.animProgress < 1) {
this.animFrame = requestAnimationFrame(step);
}
};
this.animFrame = requestAnimationFrame(step);
}
draw() {
const ctx = this.ctx;
const w = this.width;
const h = this.height;
const p = this.padding;
const chartW = w - p.left - p.right;
const chartH = h - p.top - p.bottom;
ctx.clearRect(0, 0, w, h);
const { timestamps, rx, tx } = this.data;
if (!timestamps || timestamps.length < 2) {
ctx.fillStyle = '#5a6380';
ctx.font = '13px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('等待数据...', w / 2, h / 2);
return;
}
// Find max
let maxVal = 0;
for (let i = 0; i < rx.length; i++) {
maxVal = Math.max(maxVal, rx[i] || 0, tx[i] || 0);
}
maxVal = maxVal * 1.15 || 1;
const len = timestamps.length;
const xStep = chartW / (len - 1);
// Helper to get point
const getX = (i) => p.left + i * xStep;
const getY = (val) => p.top + chartH - (val / maxVal) * chartH * this.animProgress;
// Draw grid lines
ctx.strokeStyle = 'rgba(99, 102, 241, 0.06)';
ctx.lineWidth = 1;
const gridLines = 4;
for (let i = 0; i <= gridLines; i++) {
const y = p.top + (chartH / gridLines) * i;
ctx.beginPath();
ctx.moveTo(p.left, y);
ctx.lineTo(p.left + chartW, y);
ctx.stroke();
// Y-axis labels
const val = maxVal * (1 - i / gridLines);
ctx.fillStyle = '#5a6380';
ctx.font = '10px "JetBrains Mono", monospace';
ctx.textAlign = 'right';
ctx.fillText(formatBandwidth(val, 1), p.left - 8, y + 3);
}
// X-axis labels (every ~4 hours)
ctx.fillStyle = '#5a6380';
ctx.font = '10px "JetBrains Mono", monospace';
ctx.textAlign = 'center';
const labelInterval = Math.max(1, Math.floor(len / 6));
for (let i = 0; i < len; i += labelInterval) {
const x = getX(i);
ctx.fillText(formatTime(timestamps[i]), x, h - 8);
}
// Always show last label
ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8);
// Draw TX area
this.drawArea(ctx, tx, getX, getY, chartH, p,
'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)',
'#6366f1', len);
// Draw RX area (on top)
this.drawArea(ctx, rx, getX, getY, chartH, p,
'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)',
'#06b6d4', len);
}
drawArea(ctx, values, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) {
if (!values || values.length === 0) return;
// Fill
ctx.beginPath();
ctx.moveTo(getX(0), getY(values[0] || 0));
for (let i = 1; i < len; i++) {
const prevX = getX(i - 1);
const currX = getX(i);
const prevY = getY(values[i - 1] || 0);
const currY = getY(values[i] || 0);
const midX = (prevX + currX) / 2;
ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY);
}
ctx.lineTo(getX(len - 1), p.top + chartH);
ctx.lineTo(getX(0), p.top + chartH);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, p.top, 0, p.top + chartH);
gradient.addColorStop(0, fillColorTop);
gradient.addColorStop(1, fillColorBottom);
ctx.fillStyle = gradient;
ctx.fill();
// Stroke
ctx.beginPath();
ctx.moveTo(getX(0), getY(values[0] || 0));
for (let i = 1; i < len; i++) {
const prevX = getX(i - 1);
const currX = getX(i);
const prevY = getY(values[i - 1] || 0);
const currY = getY(values[i] || 0);
const midX = (prevX + currX) / 2;
ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY);
}
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
ctx.stroke();
}
destroy() {
window.removeEventListener('resize', this._resize);
if (this.animFrame) cancelAnimationFrame(this.animFrame);
}
}

102
public/js/utils.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* Utility Functions
*/
/**
* Format bytes to human-readable string
*/
function formatBytes(bytes, decimals = 2) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k));
const value = bytes / Math.pow(k, i);
return value.toFixed(decimals) + ' ' + sizes[i];
}
/**
* Format bytes per second to human-readable bandwidth
*/
function formatBandwidth(bytesPerSec, decimals = 2) {
if (!bytesPerSec || bytesPerSec === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
const i = Math.floor(Math.log(Math.abs(bytesPerSec)) / Math.log(k));
const value = bytesPerSec / Math.pow(k, i);
return value.toFixed(decimals) + ' ' + sizes[i];
}
/**
* Format percentage
*/
function formatPercent(value, decimals = 1) {
if (!value || isNaN(value)) return '0%';
return value.toFixed(decimals) + '%';
}
/**
* Format a timestamp to HH:MM
*/
function formatTime(timestamp) {
const d = new Date(timestamp);
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
/**
* Format full clock
*/
function formatClock() {
const now = new Date();
const date = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const time = now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return `${date} ${time}`;
}
/**
* Get color based on usage percentage
*/
function getUsageColor(percent) {
if (percent < 50) return '#10b981';
if (percent < 75) return '#f59e0b';
return '#f43f5e';
}
/**
* Smooth number animation
*/
function animateValue(element, start, end, duration = 600) {
const startTime = performance.now();
const diff = end - start;
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = start + diff * eased;
if (element.dataset.format === 'percent') {
element.textContent = formatPercent(current);
} else if (element.dataset.format === 'bytes') {
element.textContent = formatBytes(current);
} else if (element.dataset.format === 'bandwidth') {
element.textContent = formatBandwidth(current);
} else {
element.textContent = Math.round(current);
}
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}