54 lines
7.7 KiB
HTML
54 lines
7.7 KiB
HTML
<!doctype html>
|
|
<html lang="en" data-theme="system">
|
|
<head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Nginx Live Traffic Monitor</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
|
<style>
|
|
:root { --chart-grid: #d9e1ea; }
|
|
[data-theme="dark"] { --chart-grid: #3b4654; }
|
|
body { background: var(--bulma-scheme-main-bis); min-height: 100vh; }
|
|
.navbar, .card { background: var(--bulma-scheme-main); }
|
|
.chart-wrap { height: 390px; position: relative; }
|
|
.metric-value { font-size: 1.35rem; font-weight: 700; white-space: nowrap; }
|
|
.metric-label { color: var(--bulma-text-weak); font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; }
|
|
.host-list { max-height: 185px; overflow-y: auto; }
|
|
.legend-dot { width: .65rem; height: .65rem; display: inline-block; border-radius: 50%; margin-right: .4rem; }
|
|
@media (max-width: 768px) { .chart-wrap { height: 280px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar is-shadowless" role="navigation"><div class="container">
|
|
<div class="navbar-brand"><div class="navbar-item"><strong>Nginx Live Traffic</strong></div></div>
|
|
<div class="navbar-end"><div class="navbar-item"><div class="select is-small"><select id="theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></div>
|
|
</div></nav>
|
|
<main class="section pt-5"><div class="container">
|
|
<div class="level mb-4"><div><h1 class="title is-4 mb-1">Traffic overview</h1><p class="subtitle is-6 mb-0" id="connection">Connecting to telemetry…</p></div>
|
|
<div class="field has-addons"><p class="control"><span class="select is-small"><select id="window"><option value="60">1 minute</option><option value="300" selected>5 minutes</option><option value="900">15 minutes</option><option value="3600">1 hour</option></select></span></p></div>
|
|
</div>
|
|
<div class="columns is-multiline mb-2" id="metrics"></div>
|
|
<div class="card mb-5"><div class="card-content"><div class="level mb-3"><div class="level-left"><h2 class="title is-5 mb-0">Throughput</h2></div><div class="level-right"><span class="tag is-light" id="updated">Waiting for data</span></div></div><div class="chart-wrap"><canvas id="traffic-chart"></canvas></div></div></div>
|
|
<div class="card"><div class="card-content"><div class="columns"><div class="column is-one-quarter"><h2 class="title is-5">Host breakouts</h2><p class="help mb-3">Select hosts to compare their recent traffic.</p><div id="hosts" class="host-list"></div></div><div class="column"><div class="chart-wrap"><canvas id="host-chart"></canvas></div></div></div></div></div>
|
|
</div></main>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
|
<script>
|
|
const palette = ['#3273dc','#23d160','#ff3860','#ffdd57','#7957d5','#00d1b2','#f14668','#209cee'];
|
|
let selected = new Set(), latest = null, source;
|
|
const $ = id => document.getElementById(id);
|
|
function bytesPerSecond(value) { const bits = value * 8; if (bits >= 1e9) return (bits/1e9).toFixed(1)+' Gbit/s'; if (bits >= 1e6) return (bits/1e6).toFixed(1)+' Mbit/s'; if (bits >= 1e3) return (bits/1e3).toFixed(1)+' Kbit/s'; return Math.round(bits)+' bit/s'; }
|
|
function duration(v) { return v == null ? '—' : v.toFixed(3)+' s'; }
|
|
function applyTheme(value) { const actual = value === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : value; document.documentElement.dataset.theme = actual; document.documentElement.style.colorScheme = actual; if (typeof trafficChart !== 'undefined') { trafficChart.options.scales.x.grid.color = getComputedStyle(document.documentElement).getPropertyValue('--chart-grid'); hostChart.options.scales.x.grid.color = trafficChart.options.scales.x.grid.color; trafficChart.update(); hostChart.update(); } }
|
|
function metric(label, value) { return `<div class="column is-half-mobile is-one-quarter-tablet"><div class="card"><div class="card-content py-4"><div class="metric-label">${label}</div><div class="metric-value">${value}</div></div></div></div>`; }
|
|
function renderMetrics(s) { const status = s.status.reduce((a,b)=>a+b,0); const errors = status ? ((s.status[2]+s.status[3])/status*100).toFixed(1)+'%' : '0.0%'; $('metrics').innerHTML = metric('Inbound', bytesPerSecond(s.incoming))+metric('Outbound', bytesPerSecond(s.outgoing))+metric('Requests', s.requests.toFixed(1)+'/sec')+metric('Transferred', formatBytes(s.total_incoming+s.total_outgoing))+metric('Avg latency', duration(s.latency))+metric('p95 latency', duration(s.p95))+metric('Upstream avg', duration(s.upstream))+metric('Errors (4xx/5xx)', errors)+metric('Active hosts', s.active_hosts)+metric('Busiest host', s.busiest_host || '—'); }
|
|
function formatBytes(n) { const units=['B','KB','MB','GB','TB']; let i=0; while(n>=1000 && i<units.length-1){n/=1000;i++;} return (i? n.toFixed(1):Math.round(n))+' '+units[i]; }
|
|
function makeChart(id, datasets) { return new Chart($(id), {type:'line', data:{labels:[],datasets}, options:{animation:false, maintainAspectRatio:false, interaction:{mode:'index',intersect:false}, scales:{x:{grid:{color:getComputedStyle(document.documentElement).getPropertyValue('--chart-grid')},ticks:{maxTicksLimit:8}},y:{beginAtZero:true,ticks:{callback:v=>bytesPerSecond(v)}}}, plugins:{legend:{display:true}}}}); }
|
|
const trafficChart = makeChart('traffic-chart',[{label:'Inbound',data:[],borderColor:'#3273dc',backgroundColor:'#3273dc',tension:.25,pointRadius:0},{label:'Outbound',data:[],borderColor:'#23d160',backgroundColor:'#23d160',tension:.25,pointRadius:0}]);
|
|
const hostChart = makeChart('host-chart',[]);
|
|
function render(data) { latest=data; const windowSize=Number($('window').value); const points=data.points.slice(-windowSize); trafficChart.data.labels=points.map(p=>new Date(p.time*1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})); trafficChart.data.datasets[0].data=points.map(p=>p.incoming); trafficChart.data.datasets[1].data=points.map(p=>p.outgoing); trafficChart.update(); hostChart.data.labels=trafficChart.data.labels; hostChart.data.datasets=[...selected].map((host,i)=>({label:host,data:(data.host_points[host]||[]).slice(-windowSize).map(p=>p.incoming+p.outgoing),borderColor:palette[i%palette.length],tension:.25,pointRadius:0})); hostChart.update(); renderMetrics(data.summary); $('updated').textContent='Updated '+new Date().toLocaleTimeString(); }
|
|
function connect() { if(source) source.close(); source=new EventSource('/api/events?window='+$('window').value); source.onopen=()=>{$('connection').textContent='Live · updates every second';}; source.onmessage=e=>render(JSON.parse(e.data)); source.onerror=()=>{$('connection').textContent='Reconnecting…';}; }
|
|
function renderHosts(hosts) { $('hosts').innerHTML=hosts.map((host,i)=>`<label class="checkbox is-block mb-2"><input type="checkbox" value="${escapeHtml(host)}" ${selected.has(host)?'checked':''}> <span class="legend-dot" style="background:${palette[i%palette.length]}"></span>${escapeHtml(host)}</label>`).join('') || '<p class="has-text-grey">No hosts observed yet.</p>'; document.querySelectorAll('#hosts input').forEach(input=>input.onchange=()=>{input.checked?selected.add(input.value):selected.delete(input.value); if(latest) render(latest);}); }
|
|
function escapeHtml(s) { return s.replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
|
const oldRender=render; render=data=>{oldRender(data);renderHosts(data.hosts);};
|
|
$('theme').value=localStorage.getItem('nginx-theme')||'system'; applyTheme($('theme').value); $('theme').onchange=()=>{localStorage.setItem('nginx-theme',$('theme').value);applyTheme($('theme').value);}; $('window').onchange=connect; connect();
|
|
</script>
|
|
</body></html>
|