Improve request timestamps and dashboard metric styling

This commit is contained in:
Jordan Wages 2026-10-13 22:44:10 -05:00
commit b9bc3469eb

View file

@ -11,6 +11,8 @@
.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; }
@ -38,12 +40,12 @@
<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 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></tr></thead>
<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>
@ -73,9 +75,17 @@ 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) { 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', `<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 || '—')); }
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}]);
@ -87,7 +97,7 @@ function escapeHtml(s) { return s.replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;'
let openedRequest = null, openerId = null, modalGeneration = 0;
function requestFields(record) {
return [
['Request ID', String(record.id)], ['Completed at', new Date(record.timestamp*1000).toISOString()],
['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'],
@ -131,6 +141,12 @@ function renderRequests(data) {
}
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;
@ -141,6 +157,12 @@ function renderRequests(data) {
(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();