nginx-realtime-traffic-dash.../app/static/index.html

198 lines
16 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="/static/vendor/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; }
#updated { background: var(--bulma-scheme-main-ter); color: var(--bulma-text-strong); }
.metric-wide .metric-value { white-space: normal; overflow-wrap: anywhere; }
.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; }
#request-rows tr { cursor: pointer; }
#request-rows tr:focus-visible { outline: 2px solid var(--bulma-link); outline-offset: -2px; }
#request-dialog { width: min(640px, calc(100% - 2rem)); max-height: calc(100vh - 2rem); overflow: auto; border: 0; border-radius: 12px; padding: 1.5rem; color: var(--bulma-text); background: var(--bulma-scheme-main); }
#request-dialog::backdrop { background: rgb(0 0 0 / .55); }
#request-details { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .65rem 1.25rem; }
#request-details dt { font-weight: 600; }
#request-details dd { overflow-wrap: anywhere; }
#request-copy-text { width: 100%; min-height: 220px; }
#request-copy-text[hidden] { display: none; }
.request-size { white-space: nowrap; }
@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="notification is-danger is-light" id="frontend-error" hidden></div>
<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" id="updated">Waiting for data</span></div></div><div class="chart-wrap"><canvas id="traffic-chart"></canvas></div></div></div>
<div class="card mb-5"><div class="card-content">
<h2 class="title is-5 mb-2" id="top-requests-title" tabindex="-1">Top 10 Requests</h2>
<p class="help mb-4">Completed requests in the selected window, ranked by their larger inbound or outbound size. Select a row for details.</p>
<div class="table-container"><table class="table is-fullwidth is-hoverable" aria-labelledby="top-requests-title">
<thead><tr><th scope="col">Rank</th><th scope="col">Direction</th><th scope="col">Host</th><th scope="col">Client address</th><th scope="col">Size</th><th scope="col">Completed</th></tr></thead>
<tbody id="request-rows"></tbody>
</table></div>
<p id="requests-empty" class="has-text-grey">No completed requests in this window.</p>
</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>
<dialog id="request-dialog" aria-labelledby="request-dialog-title">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-4"><h2 class="title is-5 mb-0" id="request-dialog-title">Request details</h2><button class="delete" id="request-close" aria-label="Close request details" autofocus></button></div>
<dl id="request-details" class="mb-5"></dl>
<button class="button is-link" id="request-copy">Copy all details</button>
<p id="request-copy-status" class="help mt-2" role="status"></p>
<textarea id="request-copy-text" class="textarea mt-3" aria-label="Request details to copy manually" readonly hidden></textarea>
</dialog>
<script>
window.addEventListener('error', event => {
const box = document.getElementById('frontend-error');
if (box) {
box.hidden = false;
box.textContent = 'Dashboard error: ' + (event.message || 'frontend initialization failed');
}
});
</script>
<script src="/static/vendor/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 formatTimestamp(timestamp) {
return new Date(timestamp*1000).toLocaleString(undefined, {year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit', second:'2-digit', timeZoneName:'short'});
}
function timeAgo(timestamp, now = Date.now()) {
const seconds = Math.max(0, Math.floor((now - timestamp*1000)/1000));
const [value, unit] = seconds < 60 ? [seconds, 'second'] : seconds < 3600 ? [Math.floor(seconds/60), 'minute'] : seconds < 86400 ? [Math.floor(seconds/3600), 'hour'] : [Math.floor(seconds/86400), 'day'];
return `${value} ${unit}${value === 1 ? '' : 's'} ago`;
}
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, wide = false) { return `<div class="column ${wide ? 'is-full-mobile is-three-quarters-tablet metric-wide' : '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', `<span title="${s.total_incoming+s.total_outgoing} bytes">${formatBytes(s.total_incoming+s.total_outgoing)}</span>`)+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', escapeHtml(s.busiest_host || '—'), true); }
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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
let openedRequest = null, openerId = null, modalGeneration = 0;
function requestFields(record) {
return [
['Request ID', String(record.id)], ['Completed at', formatTimestamp(record.timestamp)],
['Host', record.host], ['Client address', record.client_address || 'Unavailable'],
['Method', record.method], ['Path', record.path || 'Unavailable'], ['Status', String(record.status)],
['Direction', record.direction === 'inbound' ? 'Inbound' : 'Outbound'],
['Inbound size', formatBytes(record.incoming), record.incoming],
['Outbound size', formatBytes(record.outgoing), record.outgoing],
['Request duration', duration(record.request_time)], ['Upstream average', duration(record.upstream_time)]
];
}
function requestCopyText(record) {
return requestFields(record).map(([label, value, bytes]) => `${label}: ${value}${bytes === undefined ? '' : ` (${bytes} bytes)`}`).join('\n');
}
function openRequest(record) {
openedRequest = {...record};
openerId = String(record.id);
modalGeneration++;
$('request-details').replaceChildren();
for (const [label, value, bytes] of requestFields(openedRequest)) {
const term = document.createElement('dt'), detail = document.createElement('dd');
term.textContent = label;
detail.textContent = value;
if (bytes !== undefined) detail.title = `${bytes} bytes`;
$('request-details').append(term, detail);
}
$('request-copy-status').textContent = '';
$('request-copy-text').hidden = true;
$('request-copy-text').value = '';
$('request-dialog').showModal();
}
function renderRequests(data) {
const focusedId = document.activeElement?.dataset.requestId;
const records = data.top_requests_by_window?.[$('window').value] || [];
const rows = records.map((record, index) => {
const row = document.createElement('tr');
row.tabIndex = 0;
row.dataset.requestId = String(record.id);
const inbound = record.direction === 'inbound';
for (const value of [String(index+1), `${inbound ? '↓ Inbound' : '↑ Outbound'}`, record.host, record.client_address || 'Unavailable', formatBytes(record.size)]) {
const cell = document.createElement('td');
cell.textContent = value;
row.append(cell);
}
row.lastElementChild.title = `${record.size} bytes`;
row.lastElementChild.className = 'request-size';
const completed = document.createElement('td');
completed.dataset.timestamp = String(record.timestamp);
completed.textContent = timeAgo(record.timestamp);
completed.title = formatTimestamp(record.timestamp);
completed.style.whiteSpace = 'nowrap';
row.append(completed);
row.onclick = () => openRequest(record);
row.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openRequest(record); } };
return row;
});
$('request-rows').replaceChildren(...rows);
$('requests-empty').hidden = records.length > 0;
if (focusedId && !$('request-dialog').open) {
(rows.find(row => row.dataset.requestId === focusedId) || $('top-requests-title')).focus();
}
}
setInterval(() => {
const now = Date.now();
document.querySelectorAll('#request-rows [data-timestamp]').forEach(cell => {
cell.textContent = timeAgo(Number(cell.dataset.timestamp), now);
});
}, 1000);
$('request-close').onclick = () => $('request-dialog').close();
$('request-dialog').addEventListener('click', event => {
const bounds = $('request-dialog').getBoundingClientRect();
if (event.target === $('request-dialog') && (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom)) $('request-dialog').close();
});
$('request-dialog').addEventListener('close', () => {
openedRequest = null;
modalGeneration++;
$('request-details').replaceChildren();
$('request-copy-text').value = '';
const opener = [...$('request-rows').children].find(row => row.dataset.requestId === openerId);
(opener || $('top-requests-title')).focus();
openerId = null;
});
$('request-copy').onclick = async () => {
if (!openedRequest) return;
const text = requestCopyText(openedRequest), generation = modalGeneration;
try {
await navigator.clipboard.writeText(text);
if (generation === modalGeneration) $('request-copy-status').textContent = 'Copied all request details.';
} catch {
if (generation !== modalGeneration) return;
$('request-copy-status').textContent = 'Clipboard unavailable. Select and copy the details below.';
$('request-copy-text').hidden = false;
$('request-copy-text').value = text;
$('request-copy-text').focus();
$('request-copy-text').select();
}
};
const oldRender=render; render=data=>{oldRender(data);renderHosts(data.hosts);renderRequests(data);};
$('theme').value=localStorage.getItem('nginx-theme')||'system'; applyTheme($('theme').value); $('theme').onchange=()=>{localStorage.setItem('nginx-theme',$('theme').value);applyTheme($('theme').value);}; $('window').onchange=()=>{if(latest) render(latest); connect();}; connect();
</script>
</body></html>