diff --git a/public/css/style.css b/public/css/style.css index 374f760..2087914 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -656,6 +656,14 @@ input:checked+.slider:before { background: var(--accent-indigo); } +.legend-p95 { + background: var(--accent-rose); + height: 2px; + width: 12px; + border-top: 1px dashed var(--accent-rose); + background: transparent; +} + .chart-body { padding: 12px 22px; height: 280px; @@ -700,6 +708,14 @@ input:checked+.slider:before { color: var(--accent-cyan); } +.traffic-stat-p95 .traffic-value { + color: var(--accent-rose); +} + +.traffic-stat-time .traffic-value { + color: var(--accent-cyan); +} + /* ---- Gauges ---- */ .gauges-container { display: flex; diff --git a/public/index.html b/public/index.html index 407e609..426d335 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,6 @@ + @@ -7,24 +8,26 @@ 数据可视化展示大屏 - + +
@@ -47,14 +51,15 @@ @@ -99,10 +121,10 @@
- - - - + + + +
@@ -113,12 +135,16 @@
- - - - - - + + + + + + + + + +
@@ -130,8 +156,8 @@
- - + +
@@ -143,9 +169,9 @@
- - - + + +
@@ -157,7 +183,7 @@
- +
@@ -176,7 +202,7 @@

- + 网络流量趋势 (24h)

@@ -185,6 +211,7 @@
接收 (RX) 发送 (TX) + 95计费 (P95)
@@ -199,10 +226,18 @@ 24h 发送总量 0 B
+
+ 95计费带宽 + 0 B/s +
24h 总流量 0 B
+
+ 当前时间 + 00:00:00 +
@@ -213,10 +248,10 @@

- - - - + + + + 服务器详情

@@ -312,7 +347,8 @@
- @@ -346,8 +382,11 @@
- - + +
@@ -357,4 +396,5 @@ - + + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index f31ccde..b75087c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -25,6 +25,7 @@ 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'), @@ -58,7 +59,8 @@ closeLoginModal: document.getElementById('closeLoginModal'), loginForm: document.getElementById('loginForm'), loginError: document.getElementById('loginError'), - gaugesTime: document.getElementById('gaugesTime') + gaugesTime: document.getElementById('gaugesTime'), + footerTime: document.getElementById('footerTime') }; // ---- State ---- @@ -120,7 +122,7 @@ // Start data fetching fetchMetrics(); fetchNetworkHistory(); - + // Site settings if (window.SITE_SETTINGS) { applySiteSettings(window.SITE_SETTINGS); @@ -128,14 +130,14 @@ 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); @@ -155,7 +157,7 @@ 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); @@ -259,8 +261,12 @@ } function updateGaugesTime() { + const clockStr = formatClock(); if (dom.gaugesTime) { - dom.gaugesTime.textContent = formatClock(); + dom.gaugesTime.textContent = clockStr; + } + if (dom.footerTime) { + dom.footerTime.textContent = clockStr; } } @@ -387,6 +393,9 @@ 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); } @@ -419,7 +428,7 @@ try { const response = await fetch('/api/settings'); const settings = await response.json(); - + window.SITE_SETTINGS = settings; // Cache it globally // Update inputs @@ -427,7 +436,7 @@ dom.siteTitleInput.value = settings.title || ''; dom.logoUrlInput.value = settings.logo_url || ''; dom.defaultThemeInput.value = settings.default_theme || 'dark'; - + // Apply to UI applySiteSettings(settings); @@ -457,7 +466,7 @@ if (settings.title) { dom.logoText.textContent = settings.title; } - + // Logo Icon if (settings.logo_url) { dom.logoIconContainer.innerHTML = `Logo`; diff --git a/public/js/chart.js b/public/js/chart.js index 9f39b65..01f21de 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -47,6 +47,18 @@ class AreaChart { this.data = data; } + // Calculate P95 (95th percentile) + // Common standard: 95th percentile of the peak (max of rx/tx or sum) + // We'll use max(rx, tx) at each point which is common for billing + const combined = data.rx.map((r, i) => Math.max(r || 0, data.tx[i] || 0)); + if (combined.length > 0) { + const sorted = [...combined].sort((a, b) => a - b); + const p95Idx = Math.floor(sorted.length * 0.95); + this.p95 = sorted[p95Idx]; + } else { + this.p95 = null; + } + this.animProgress = 0; this.animate(); } @@ -173,6 +185,35 @@ class AreaChart { this.drawArea(ctx, rx, getX, getY, chartH, p, 'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', '#06b6d4', len); + + // Draw P95 line + if (this.p95 && this.animProgress === 1) { + const p95Y = getY(this.p95); + // Only draw if within visible range + if (p95Y >= p.top && p95Y <= p.top + chartH) { + ctx.save(); + ctx.beginPath(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)'; // --accent-rose + ctx.lineWidth = 1.5; + ctx.moveTo(p.left, p95Y); + ctx.lineTo(p.left + chartW, p95Y); + ctx.stroke(); + + // P95 label background + const label = '95计费: ' + (window.formatBandwidth ? window.formatBandwidth(this.p95) : this.p95.toFixed(2)); + ctx.font = 'bold 11px "JetBrains Mono", monospace'; + const metrics = ctx.measureText(label); + ctx.fillStyle = 'rgba(244, 63, 94, 0.15)'; + ctx.fillRect(p.left + 8, p95Y - 20, metrics.width + 12, 18); + + // P95 label text + ctx.fillStyle = '#f43f5e'; + ctx.textAlign = 'left'; + ctx.fillText(label, p.left + 14, p95Y - 7); + ctx.restore(); + } + } } drawArea(ctx, values, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) {