Add live top requests table and request detail overlay
This commit is contained in:
parent
61630b4adb
commit
e4148de161
4 changed files with 315 additions and 14 deletions
|
|
@ -14,6 +14,16 @@
|
|||
.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>
|
||||
|
|
@ -29,8 +39,24 @@
|
|||
</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">
|
||||
<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>
|
||||
<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');
|
||||
|
|
@ -49,7 +75,7 @@ function bytesPerSecond(value) { const bits = value * 8; if (bits >= 1e9) return
|
|||
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 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 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}]);
|
||||
|
|
@ -58,7 +84,93 @@ function render(data) { latest=data; const windowSize=Number($('window').value);
|
|||
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();
|
||||
let openedRequest = null, openerId = null, modalGeneration = 0;
|
||||
function requestFields(record) {
|
||||
return [
|
||||
['Request ID', String(record.id)], ['Completed at', new Date(record.timestamp*1000).toISOString()],
|
||||
['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';
|
||||
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();
|
||||
}
|
||||
}
|
||||
$('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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue