First Commit
This commit is contained in:
433
public/js/app.js
Normal file
433
public/js/app.js
Normal 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
176
public/js/chart.js
Normal 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
102
public/js/utils.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user