wireguard-ui/templates/traffic.html

622 lines
25 KiB
HTML

{{define "title"}}{{ tr .UILang "traffic.title" }}{{end}}
{{define "top_css"}}
<style>
.wg-t-kpi{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px;width:100%;}
.wg-t-kpi .k{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;}
.wg-t-kpi-sub{font-size:11px;color:var(--t3);margin-top:6px;font-weight:600}
.wg-t-kpi-hint{font-size:9px;color:#757575;margin-top:6px;line-height:1.3}
.wg-t-kpi .k{background:#1e1e1e;border-radius:16px;padding:14px;border:1px solid rgba(255,255,255,.06);}
.wg-t-kpi .k .lbl{font-size:10px;color:#9e9e9e;text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px}
.wg-t-kpi .k .rx{color:#EF5350;font-size:22px;font-weight:800}
.wg-t-kpi .k .tx{color:#FFCA28;font-size:22px;font-weight:800}
.wg-t-band-card{background:#1e1e1e;border-radius:16px;border:1px solid rgba(255,255,255,.06);padding:14px 14px 10px;margin-bottom:14px;}
.wg-t-band-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px;}
.wg-t-band-title{font-size:12px;color:#eaeaea;font-weight:800;}
.wg-t-band-sub{font-size:10px;color:#757575;}
.wg-t-chart{position:relative;display:flex;align-items:stretch;height:200px;gap:8px;overflow-x:auto;overflow-y:hidden;padding:6px 2px 4px;}
.wg-t-chart::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(255,255,255,.08);pointer-events:none;}
.wg-t-bar{position:relative;display:block;min-width:22px;flex:0 0 22px;height:100%;}
.wg-t-seg{position:absolute;left:0;width:100%;border-radius:3px;}
.wg-t-seg-up{bottom:50%;background:rgba(239,83,80,.95);}
.wg-t-seg-down{top:50%;background:rgba(255,202,40,.95);}
.wg-t-legend{display:flex;align-items:center;gap:10px;margin-top:8px;}
.wg-t-legend .lg{display:flex;align-items:center;gap:7px;color:#757575;font-size:11px;font-weight:700;}
.wg-t-legend .dot{width:10px;height:10px;border-radius:3px;background:#EF5350;}
.wg-t-legend .dot.tx{background:#FFCA28;}
.wg-t-mini-hint{font-size:9px;color:#757575;margin-top:6px;font-style:italic;}
.wg-t-range-tabs{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap}
.wg-t-range-btn{border:1px solid rgba(255,255,255,.12);background:transparent;color:var(--t2,#9e9e9e);padding:6px 12px;border-radius:999px;font-size:11px;font-weight:700;cursor:pointer;line-height:1.2}
.wg-t-range-btn:hover{color:var(--t1,#eee);border-color:rgba(239,83,80,.35)}
.wg-t-range-btn.active{background:rgba(239,83,80,.14);border-color:rgba(239,83,80,.45);color:#EF5350}
.wg-t-table-wrap{overflow-x:auto;}
.wg-traf-table{font-size:12px;color:#eaeaea;background:#1e1e1e;border:1px solid rgba(255,255,255,.06);border-radius:16px;overflow:hidden;margin:0;width:100%}
.wg-traf-table th{font-size:9px;text-transform:uppercase;color:#757575;font-weight:700;background:#252525;padding:10px;border-bottom:1px solid rgba(255,255,255,.06);text-align:left}
.wg-traf-table td{border-top:1px solid rgba(255,255,255,.06);padding:10px;font-family:ui-monospace,monospace;vertical-align:top;}
.wg-rx{color:#EF5350;font-weight:600}
.wg-txc{color:#FFCA28;font-weight:600}
</style>
{{end}}
{{define "username"}} {{ .username }} {{end}}
{{define "page_title"}}{{ tr .UILang "traffic.page" }}{{end}}
{{define "page_content"}}
<section class="content wg-shell-page">
{{ if .error }}<div class="alert alert-danger">{{ .error }}</div>{{ end }}
{{ if not .error }}
<div class="wg-t-kpi">
<div class="k">
<div class="lbl">{{ tr .UILang "traffic.kpi_dl_total" }}</div>
<div class="rx"><span id="wg-traf-total-rx"></span></div>
<div class="wg-t-kpi-hint">{{ tr .UILang "traffic.kpi_all_peers_24h" }}</div>
<div class="wg-t-kpi-sub" id="wg-traf-rate-rx-now" title="{{ tr .UILang "traffic.kpi_rate_window_title" }}"></div>
</div>
<div class="k">
<div class="lbl">{{ tr .UILang "traffic.kpi_ul_total" }}</div>
<div class="tx"><span id="wg-traf-total-tx"></span></div>
<div class="wg-t-kpi-hint">{{ tr .UILang "traffic.kpi_all_peers_24h" }}</div>
<div class="wg-t-kpi-sub" id="wg-traf-rate-tx-now" title="{{ tr .UILang "traffic.kpi_rate_window_title" }}"></div>
</div>
<div class="k b">
<div class="lbl">{{ tr .UILang "traffic.kpi_peak_dl_today" }}</div>
<div class="rx"><span id="wg-traf-peak-tx"></span></div>
<div class="wg-t-kpi-hint">{{ tr .UILang "traffic.kpi_peak_perspective" }}</div>
<div class="wg-t-kpi-sub" id="wg-traf-peak-updated"></div>
</div>
<div class="k"><div class="lbl">{{ tr .UILang "traffic.kpi_wg_ifaces" }}</div><div class="rx" style="color:#eaeaea">{{ if .devices }}{{len .devices}}{{ else }}0{{ end }}</div></div>
</div>
<div class="wg-t-band-card">
<div class="wg-t-band-head">
<div>
<div class="wg-t-band-title">{{ tr .UILang "traffic.bandwidth" }}</div>
<div class="wg-t-band-sub" id="wg-traf-range-label">{{ tr .UILang "traffic.range_24h_live" }}</div>
</div>
<div class="wg-t-range-tabs" role="tablist" aria-label="{{ tr .UILang "traffic.range_tabs_aria" }}">
<button type="button" class="wg-t-range-btn active" data-range="24h">24h</button>
<button type="button" class="wg-t-range-btn" data-range="7d">7d</button>
<button type="button" class="wg-t-range-btn" data-range="30d">30d</button>
</div>
</div>
<div class="wg-t-chart" id="wg-traf-chart" aria-label="{{ tr .UILang "traffic.chart_aria" }}"></div>
<div class="wg-t-legend">
<div class="lg"><span class="dot"></span>{{ tr .UILang "traffic.legend_dl_top" }}</div>
<div class="lg"><span class="dot tx"></span>{{ tr .UILang "traffic.legend_ul_bottom" }}</div>
</div>
<div class="wg-t-mini-hint">{{ tr .UILang "traffic.peer_perspective_hint" }}</div>
</div>
<div class="wg-t-kpi-hint" style="margin-bottom:8px;">{{ tr .UILang "traffic.table_intro" }}</div>
<div class="wg-t-table-wrap">
<table class="wg-traf-table">
<thead><tr>
<th>{{ tr .UILang "traffic.th_client" }}</th><th>{{ tr .UILang "traffic.th_ips" }}</th><th class="wg-rx">{{ tr .UILang "traffic.th_dl" }}</th><th class="wg-txc">{{ tr .UILang "traffic.th_ul" }}</th><th>{{ tr .UILang "traffic.th_handshake" }}</th>
</tr></thead>
<tbody>
{{ range $d := .devices }}
{{ range $p := .Peers }}
<tr data-peer-pubkey="{{ $p.PublicKey }}" data-peer-name="{{ if $p.Name }}{{ $p.Name }}{{ else }}{{ tr $.UILang "traffic.peer_fallback" }}{{ end }}">
<td><strong>{{ if $p.Name }}{{ $p.Name }}{{ else }}{{ tr $.UILang "traffic.peer_fallback" }}{{ end }}</strong></td>
<td style="white-space:normal;max-width:280px;line-height:1.35;text-align:left;">{{ $p.AllocatedIP }}</td>
<td class="wg-rx"><span class="wg-traf-row-rx"></span></td>
<td class="wg-txc"><span class="wg-traf-row-tx"></span></td>
<td>{{ if $p.LastHandshakeTime.IsZero }}-{{ else }}{{ $p.LastHandshakeTime.Format "02-01 15:04:05" }}{{ end }}</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</div>
{{ end }}
</section>
{{end}}
{{define "bottom_js"}}
<script>
(function(){
function fmtBytesKB(n){
var v = Number(n);
if (!isFinite(v) || v < 0) return '—';
var kb = v / 1024;
if (kb < 1) kb = 0;
if (kb < 1024) return kb.toFixed(kb >= 100 ? 0 : (kb >= 10 ? 1 : 2)) + ' KB';
var mb = kb / 1024;
if (mb < 1024) return mb.toFixed(mb >= 100 ? 0 : (mb >= 10 ? 1 : 2)) + ' MB';
var gb = mb / 1024;
if (gb < 1024) return gb.toFixed(gb >= 100 ? 0 : (gb >= 10 ? 1 : 2)) + ' GB';
var tb = gb / 1024;
return tb.toFixed(tb >= 100 ? 0 : (tb >= 10 ? 1 : 2)) + ' TB';
}
function fmtRateKbitPerSec(bytesPerSec){
var v = Number(bytesPerSec);
if (!isFinite(v) || v < 0) return '—';
var bps = v * 8; // bits/s
var kbps = bps / 1000;
if (kbps < 1000) return kbps.toFixed(kbps >= 100 ? 0 : (kbps >= 10 ? 1 : 2)) + ' Kb/s';
var mbps = kbps / 1000;
if (mbps < 1000) return mbps.toFixed(mbps >= 100 ? 0 : (mbps >= 10 ? 1 : 2)) + ' Mb/s';
var gbps = mbps / 1000;
return gbps.toFixed(gbps >= 10 ? 1 : 2) + ' Gb/s';
}
function fmtClientBytesMBGB(n){
var v = Number(n);
if (!isFinite(v) || v < 0) return '—';
var mb = v / (1024 * 1024);
if (mb < 1024) return mb.toFixed(mb >= 100 ? 0 : (mb >= 10 ? 1 : 2)) + ' MB';
var gb = mb / 1024;
if (gb < 1024) return gb.toFixed(gb >= 100 ? 0 : (gb >= 10 ? 1 : 2)) + ' GB';
var tb = gb / 1024;
return tb.toFixed(tb >= 100 ? 0 : (tb >= 10 ? 1 : 2)) + ' TB';
}
var chartEl = document.getElementById('wg-traf-chart');
if (!chartEl) return;
var basePath = {{printf "%q" .basePath}};
var pollMs = 4000;
var selectedRange = '24h';
var peerNameByPub = {};
function trafficRangeLabel(r) {
if (r === '7d') return wgT('traffic.range_7d_live');
if (r === '30d') return wgT('traffic.range_30d_live');
return wgT('traffic.range_24h_live');
}
function buildPeerNameMap(){
peerNameByPub = {};
document.querySelectorAll('tr[data-peer-pubkey]').forEach(function(tr){
var pk = (tr.getAttribute('data-peer-pubkey') || '').trim();
if (!pk) return;
var nm = (tr.getAttribute('data-peer-name') || '').trim();
peerNameByPub[pk] = nm || 'peer';
});
}
/** Per-row download/upload uses the same range as the chart (peer_totals). */
function updateTrafficTableForRange(resp){
if (!resp) return;
var byPub = {};
(resp.peer_totals || []).forEach(function(p){
var pk = String((p && p.public_key) || '');
if (!pk) return;
byPub[pk] = {
rxBytes: Number((p && p.rx_bytes) || 0),
txBytes: Number((p && p.tx_bytes) || 0)
};
});
document.querySelectorAll('.wg-traf-table tr[data-peer-pubkey]').forEach(function(tr){
var pub = (tr.getAttribute('data-peer-pubkey') || '').trim();
var t = byPub[pub];
var dl = t ? t.txBytes : 0;
var ul = t ? t.rxBytes : 0;
var rxEl = tr.querySelector('.wg-traf-row-rx');
var txEl = tr.querySelector('.wg-traf-row-tx');
if (rxEl) rxEl.textContent = fmtBytesKB(dl);
if (txEl) txEl.textContent = fmtBytesKB(ul);
});
}
function setActiveRangeButtons() {
document.querySelectorAll('.wg-t-range-btn').forEach(function(btn){
var r = btn.getAttribute('data-range');
if (r === selectedRange) btn.classList.add('active');
else btn.classList.remove('active');
});
}
/** Top KPIs: always last 24h, sum across peers (peer_totals with range=24h). */
function applyKpiFrom24h(resp){
if (!resp) return;
var rxTotalEl = document.getElementById('wg-traf-total-rx');
var txTotalEl = document.getElementById('wg-traf-total-tx');
var rxRateEl = document.getElementById('wg-traf-rate-rx-now');
var txRateEl = document.getElementById('wg-traf-rate-tx-now');
var peakEl = document.getElementById('wg-traf-peak-tx');
var updEl = document.getElementById('wg-traf-peak-updated');
var peerTotals = resp.peer_totals || [];
var sumDl = 0;
var sumUl = 0;
peerTotals.forEach(function(p){
sumDl += Number((p && p.tx_bytes) || 0);
sumUl += Number((p && p.rx_bytes) || 0);
});
if (rxTotalEl) rxTotalEl.textContent = fmtBytesKB(sumDl);
if (txTotalEl) txTotalEl.textContent = fmtBytesKB(sumUl);
var txBpsPeak = typeof resp.tx_rate_recent_max_bps !== 'undefined' && resp.tx_rate_recent_max_bps !== null
? resp.tx_rate_recent_max_bps : resp.tx_rate_now_bps;
var rxBpsPeak = typeof resp.rx_rate_recent_max_bps !== 'undefined' && resp.rx_rate_recent_max_bps !== null
? resp.rx_rate_recent_max_bps : resp.rx_rate_now_bps;
if (rxRateEl) rxRateEl.textContent = (typeof txBpsPeak !== 'undefined') ? fmtRateKbitPerSec(txBpsPeak) : '—';
if (txRateEl) txRateEl.textContent = (typeof rxBpsPeak !== 'undefined') ? fmtRateKbitPerSec(rxBpsPeak) : '—';
if (peakEl) {
var peak = Number(resp.peak_peer_download_mbps != null ? resp.peak_peer_download_mbps : resp.peak_tx_mbps);
if (!isFinite(peak) || peak < 0) peak = 0;
peakEl.textContent = (peak < 10 ? peak.toFixed(2) : peak.toFixed(1)) + ' Mb/s';
}
if (updEl) {
var age = Number(resp.updated_age_secs || 0);
updEl.textContent = String(wgT('traffic.kpi_updated_ago')).replace(/\{s\}/g, String(age));
}
}
function renderBandwidthChart(resp){
if (!resp) return;
var rangeLabel = document.getElementById('wg-traf-range-label');
if (rangeLabel) rangeLabel.textContent = trafficRangeLabel(selectedRange);
chartEl.innerHTML = '';
var peers = resp.peer_totals || [];
if (!peers.length) peers = resp.peer_current_totals || [];
var maxPeerBytes = 0;
for (var i=0;i<peers.length;i++){
var pi = peers[i] || {};
var rxBi = Number(pi.rx_bytes || 0);
var txBi = Number(pi.tx_bytes || 0);
if (rxBi > maxPeerBytes) maxPeerBytes = rxBi;
if (txBi > maxPeerBytes) maxPeerBytes = txBi;
}
if (maxPeerBytes <= 0) maxPeerBytes = 1;
for (var j=0;j<peers.length;j++){
var p = peers[j] || {};
var pub = String(p.public_key || '');
var nm = pub ? (peerNameByPub[pub] || wgT('traffic.peer_fallback')) : wgT('traffic.peer_fallback');
var rxBytes = Number(p.tx_bytes || 0); // peer download = server TX
var txBytes = Number(p.rx_bytes || 0); // peer upload = server RX
var upH = (rxBytes / maxPeerBytes) * 50; // top half (descarga)
var downH = (txBytes / maxPeerBytes) * 50; // bottom half (subida)
var bar = document.createElement('div');
bar.className = 'wg-t-bar';
var segUp = document.createElement('div');
segUp.className = 'wg-t-seg wg-t-seg-up';
segUp.style.height = upH + '%';
bar.appendChild(segUp);
var segDown = document.createElement('div');
segDown.className = 'wg-t-seg wg-t-seg-down';
segDown.style.height = downH + '%';
bar.appendChild(segDown);
bar.title = String(wgT('traffic.bar_title'))
.replace(/\{name\}/g, nm)
.replace(/\{dl\}/g, fmtClientBytesMBGB(rxBytes))
.replace(/\{ul\}/g, fmtClientBytesMBGB(txBytes));
chartEl.appendChild(bar);
}
}
async function fetchSeries(){
try{
var url24 = basePath + '/api/wg-traffic-series?range=24h';
var urlSel = basePath + '/api/wg-traffic-series?range=' + encodeURIComponent(selectedRange);
var p24 = fetch(url24, { credentials: 'same-origin' }).then(function(r){ return r.ok ? r.json() : null; });
var data24 = await p24;
applyKpiFrom24h(data24);
var dataChart;
if (selectedRange === '24h') {
dataChart = data24;
} else {
var r = await fetch(urlSel, { credentials: 'same-origin' });
if (!r.ok) return;
dataChart = await r.json();
}
renderBandwidthChart(dataChart);
updateTrafficTableForRange(dataChart);
}catch(e){
// ignore
}
}
document.querySelectorAll('.wg-t-range-btn').forEach(function(btn){
btn.addEventListener('click', function(){
selectedRange = btn.getAttribute('data-range') || '24h';
setActiveRangeButtons();
fetchSeries();
});
});
setActiveRangeButtons();
buildPeerNameMap();
fetchSeries();
setInterval(fetchSeries, pollMs);
// Skip legacy logic that used to live below this script.
return;
function fmtBytesKB(n){
var v = Number(n);
if (!isFinite(v) || v < 0) return '—';
var kb = v / 1024;
if (kb < 1) kb = 0;
if (kb < 1024) return kb.toFixed(kb >= 100 ? 0 : (kb >= 10 ? 1 : 2)) + ' KB';
var mb = kb / 1024;
if (mb < 1024) return mb.toFixed(mb >= 100 ? 0 : (mb >= 10 ? 1 : 2)) + ' MB';
var gb = mb / 1024;
if (gb < 1024) return gb.toFixed(gb >= 100 ? 0 : (gb >= 10 ? 1 : 2)) + ' GB';
var tb = gb / 1024;
return tb.toFixed(tb >= 100 ? 0 : (tb >= 10 ? 1 : 2)) + ' TB';
}
// Mb/s = bytes/s * 8 / 1e6
function fmtMbpsFromBytesPerSec(bytesPerSec){
var v = Number(bytesPerSec);
if (!isFinite(v) || v < 0) return 0;
return (v * 8) / 1e6;
}
// Formato tasas en Kb/s y Mb/s (kilobit / megabit por segundo).
function fmtRateKbitPerSec(bytesPerSec){
var v = Number(bytesPerSec);
if (!isFinite(v) || v < 0) return '—';
// bits/s
var bps = v * 8;
var kbps = bps / 1000;
if (kbps < 1000) return kbps.toFixed(kbps >= 100 ? 0 : (kbps >= 10 ? 1 : 2)) + ' Kb/s';
var mbps = kbps / 1000;
if (mbps < 1000) return mbps.toFixed(mbps >= 100 ? 0 : (mbps >= 10 ? 1 : 2)) + ' Mb/s';
var gbps = mbps / 1000;
return gbps.toFixed(gbps >= 10 ? 1 : 2) + ' Gb/s';
}
var chartEl = document.getElementById('wg-traf-chart');
if (!chartEl) return;
var basePath = {{printf "%q" .basePath}};
var pollMs = 5000;
var baseBucketMs = 30 * 60 * 1000; // 30 min granularity (=> 1440 buckets for 30d)
var maxBaseBuckets = 1440; // 30d * 24h * 2
// Display configs
var ranges = {
'24h': { windowBaseBuckets: 48, displayCount: 48, groupFactor: 1, label: 'últimas 24h (estimado en vivo)' },
'7d': { windowBaseBuckets: 336, displayCount: 56, groupFactor: 6, label: 'últimos 7 días (estimado en vivo)' },
'30d': { windowBaseBuckets: 1440,displayCount: 60, groupFactor: 24, label: 'últimos 30 días (estimado en vivo)' },
};
var selectedRange = '24h';
var baseBucketId = new Array(maxBaseBuckets).fill(null);
var rxSum = new Array(maxBaseBuckets).fill(0);
var rxCount = new Array(maxBaseBuckets).fill(0);
var txSum = new Array(maxBaseBuckets).fill(0);
var txCount = new Array(maxBaseBuckets).fill(0);
var prev = null; // {t, rx, tx}
var lastDataAtMs = null;
// Warm start: reuse last totals from previous visits so first load can render immediately.
// This avoids the "blank chart until it updates" effect.
try {
var stored = localStorage.getItem('wg-traf-prev-totals');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && typeof parsed.t !== 'undefined' && typeof parsed.rx !== 'undefined' && typeof parsed.tx !== 'undefined') {
prev = parsed;
}
}
} catch (e) { /* ignore */ }
function bidFor(ts){ return Math.floor(ts / baseBucketMs); }
function idxFor(bid){ return ((bid % maxBaseBuckets) + maxBaseBuckets) % maxBaseBuckets; }
function resetBaseBucketAt(bid){
var idx = idxFor(bid);
baseBucketId[idx] = bid;
rxSum[idx] = 0; rxCount[idx] = 0;
txSum[idx] = 0; txCount[idx] = 0;
}
function sumPeerStats(stats){
var rx = 0, tx = 0;
if (!stats) return {rx:0, tx:0};
for (var k in stats){
if (!Object.prototype.hasOwnProperty.call(stats,k)) continue;
var row = stats[k] || {};
rx += Number(row.rx || 0);
tx += Number(row.tx || 0);
}
return {rx:rx, tx:tx};
}
function fmtAge(now){
if (!lastDataAtMs) return '';
var diff = now - lastDataAtMs;
var sec = Math.max(0, Math.floor(diff/1000));
return 'actualizado hace ' + sec + 's';
}
function updateStaticTotals(){
var rxTotalEl = document.getElementById('wg-traf-total-rx');
var txTotalEl = document.getElementById('wg-traf-total-tx');
if (rxTotalEl) rxTotalEl.textContent = fmtBytesKB(rxTotalEl.getAttribute('data-wg-bytes'));
if (txTotalEl) txTotalEl.textContent = fmtBytesKB(txTotalEl.getAttribute('data-wg-bytes'));
document.querySelectorAll('.wg-traf-bytes[data-wg-bytes]').forEach(function(el){
el.textContent = fmtBytesKB(el.getAttribute('data-wg-bytes'));
});
}
function currentKpisUpdate(rxRateBps, txRateBps, totals){
var rxRateEl = document.getElementById('wg-traf-rate-rx-now');
var txRateEl = document.getElementById('wg-traf-rate-tx-now');
if (rxRateEl) rxRateEl.textContent = rxRateBps != null ? fmtRateKbitPerSec(rxRateBps) : '—';
if (txRateEl) txRateEl.textContent = txRateBps != null ? fmtRateKbitPerSec(txRateBps) : '—';
// totals
var rxTotalEl = document.getElementById('wg-traf-total-rx');
var txTotalEl = document.getElementById('wg-traf-total-tx');
if (totals){
if (rxTotalEl && typeof totals.rx !== 'undefined') rxTotalEl.textContent = fmtBytesKB(totals.rx);
if (txTotalEl && typeof totals.tx !== 'undefined') txTotalEl.textContent = fmtBytesKB(totals.tx);
}
}
function computePeakTodayMb(now){
// scan last 24h base buckets (48)
var cfg = ranges['24h'];
var peak = 0;
var peakBid = null;
var currentBid = bidFor(now);
var startBid = currentBid - (cfg.windowBaseBuckets - 1);
for (var i=0;i<cfg.windowBaseBuckets;i++){
var bid = startBid + i;
var idx = idxFor(bid);
if (baseBucketId[idx] !== bid) continue;
var txAvgBps = txCount[idx] ? (txSum[idx]/txCount[idx]) : 0;
var mbps = fmtMbpsFromBytesPerSec(txAvgBps);
if (mbps > peak){
peak = mbps;
peakBid = bid;
}
}
var peakEl = document.getElementById('wg-traf-peak-tx');
if (peakEl) {
if (!peak || peak <= 0) peakEl.textContent = '—';
else peakEl.textContent = (peak < 10 ? peak.toFixed(2) : peak.toFixed(1)) + ' Mb/s';
}
var upd = document.getElementById('wg-traf-peak-updated');
if (upd) upd.textContent = fmtAge(now) || 'actualizado hace —';
}
function renderChart(now){
var cfg = ranges[selectedRange];
if (!cfg) return;
var currentBid = bidFor(now);
var startBid = currentBid - (cfg.windowBaseBuckets - 1);
var maxTotal = 0;
var bars = new Array(cfg.displayCount);
for (var i=0;i<cfg.displayCount;i++){
var baseStartBid = startBid + i*cfg.groupFactor;
var rxSumGroup = 0, rxCountGroup = 0;
var txSumGroup = 0, txCountGroup = 0;
for (var g=0; g<cfg.groupFactor; g++){
var bid = baseStartBid + g;
var idx = idxFor(bid);
if (baseBucketId[idx] !== bid) continue;
rxSumGroup += rxSum[idx];
rxCountGroup += rxCount[idx];
txSumGroup += txSum[idx];
txCountGroup += txCount[idx];
}
var rxAvg = rxCountGroup ? (rxSumGroup / rxCountGroup) : 0;
var txAvg = txCountGroup ? (txSumGroup / txCountGroup) : 0;
var total = rxAvg + txAvg;
if (total > maxTotal) maxTotal = total;
bars[i] = { rxAvg: rxAvg, txAvg: txAvg, total: total };
}
if (maxTotal <= 0) maxTotal = 1;
chartEl.innerHTML = '';
for (var j=0;j<bars.length;j++){
var b = bars[j];
var barTotalPct = (b.total / maxTotal) * 100;
var rxPct = b.total > 0 ? (b.rxAvg / b.total) : 0;
var rxH = barTotalPct * rxPct;
var txH = barTotalPct - rxH;
var bar = document.createElement('div');
bar.className = 'wg-t-bar';
// RX on top
var segRx = document.createElement('div');
segRx.className = 'wg-t-seg wg-t-seg-rx';
segRx.style.height = rxH + '%';
segRx.title = 'Descarga: ' + fmtRateKbitPerSec(b.rxAvg);
bar.appendChild(segRx);
// TX bottom
var segTx = document.createElement('div');
segTx.className = 'wg-t-seg wg-t-seg-tx';
segTx.style.height = txH + '%';
segTx.title = 'Subida: ' + fmtRateKbitPerSec(b.txAvg);
bar.appendChild(segTx);
bar.title = 'RX: ' + fmtRateKbitPerSec(b.rxAvg) + ' · TX: ' + fmtRateKbitPerSec(b.txAvg);
chartEl.appendChild(bar);
}
// peak depends on today window always
computePeakTodayMb(now);
// range label
var rangeLabel = document.getElementById('wg-traf-range-label');
if (rangeLabel && cfg.label) rangeLabel.textContent = cfg.label;
}
async function sample(){
try{
var resp = await fetch(basePath + '/api/wg-peer-stats', { credentials: 'same-origin' });
if (!resp.ok) return;
var stats = await resp.json();
var totals = sumPeerStats(stats);
var now = Date.now();
if (prev){
var dtSec = (now - prev.t) / 1000;
if (dtSec > 0){
var rxRate = (totals.rx - prev.rx) / dtSec; // bytes/s
var txRate = (totals.tx - prev.tx) / dtSec; // bytes/s
if (!isNaN(rxRate) && !isNaN(txRate)){
var bid = bidFor(now);
var idx = idxFor(bid);
if (baseBucketId[idx] !== bid) resetBaseBucketAt(bid);
rxSum[idx] += Math.max(0, rxRate);
rxCount[idx] += 1;
txSum[idx] += Math.max(0, txRate);
txCount[idx] += 1;
currentKpisUpdate(rxRate, txRate, totals);
}
}
}
prev = {t:now, rx:totals.rx, tx:totals.tx};
lastDataAtMs = now;
renderChart(now);
// Re-render once more shortly after layout settles (fixes first paint blank on some browsers/tabs).
setTimeout(function(){
try { renderChart(Date.now()); } catch (e) {}
}, 450);
try {
localStorage.setItem('wg-traf-prev-totals', JSON.stringify(prev));
} catch (e) { /* ignore */ }
}catch(e){
// ignore polling errors
}
}
// Range button handlers
document.querySelectorAll('.wg-t-range-btn').forEach(function(btn){
btn.addEventListener('click', function(){
var r = btn.getAttribute('data-range');
if (!ranges[r]) return;
selectedRange = r;
document.querySelectorAll('.wg-t-range-btn').forEach(function(x){ x.classList.remove('active'); });
btn.classList.add('active');
renderChart(Date.now());
});
});
updateStaticTotals();
renderChart(Date.now());
// Warmup: one extra sample after a short delay in case we started without localStorage.
setTimeout(function(){ sample(); }, 1200);
setInterval(sample, pollMs);
})();
</script>
{{end}}