172 lines
6.6 KiB
HTML
172 lines
6.6 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Speed Tests</title>
|
|
<style>
|
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 0; background: #0b0b10; color: #e8e8f0; }
|
|
header { padding: 1rem 1.25rem; position: sticky; top: 0; background: #0b0b10; border-bottom: 1px solid #23232b; z-index: 2; }
|
|
.wrap { max-width: 1200px; margin: 0 auto; padding: 1rem; }
|
|
.card { background: #12121a; border: 1px solid #23232b; border-radius: 16px; padding: 1rem; box-shadow: 0 4px 16px rgba(0,0,0,.25); }
|
|
h1 { font-size: 1.25rem; margin: 0; }
|
|
#chart { height: 420px; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
|
th, td { padding: 8px 10px; border-bottom: 1px solid #23232b; white-space: nowrap; }
|
|
thead th { position: sticky; top: 0; background: #12121a; z-index: 1; }
|
|
tr:hover td { background: #171723; }
|
|
.muted { color: #a0a0b8; font-size: 0.9rem; }
|
|
.controls { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: .5rem; }
|
|
input, select, button { background: #0b0b10; color: #e8e8f0; border: 1px solid #23232b; border-radius: 10px; padding: .4rem .6rem; }
|
|
button { cursor: pointer; }
|
|
#sentinel { height: 24px; }
|
|
.badge { display:inline-block; padding:.15rem .5rem; border-radius: 999px; background:#1e293b; color:#d1e7ff; font-size: .75rem; margin-left: .5rem; }
|
|
</style>
|
|
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" defer></script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="wrap">
|
|
<h1>Speed Tests <span class="badge">DB: {{ db_name }}</span></h1>
|
|
<div class="muted">down_90th vs up_90th · lazy table below</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="wrap" style="display:grid; gap: 1rem;">
|
|
<section class="card">
|
|
<div class="controls">
|
|
<label>Max points <input id="maxPoints" type="number" value="{{ SERIES_MAX_POINTS_DEFAULT }}" min="0" max="{{ SERIES_MAX_POINTS_HARD }}" step="200"></label>
|
|
<button id="reloadSeries">Reload series</button>
|
|
<span class="muted" id="seriesMeta"></span>
|
|
</div>
|
|
<div id="chart"></div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="controls">
|
|
<strong>Table</strong>
|
|
<label>Page size <input id="pageSize" type="number" value="{{ PAGE_SIZE_DEFAULT }}" min="10" max="{{ PAGE_SIZE_MAX }}" step="50"></label>
|
|
<select id="orderSel">
|
|
<option value="desc" selected>Newest first</option>
|
|
<option value="asc">Oldest first</option>
|
|
</select>
|
|
<button id="resetTable">Reset</button>
|
|
<span class="muted" id="tableMeta"></span>
|
|
</div>
|
|
<div style="overflow:auto; max-height: 420px; border:1px solid #23232b; border-radius: 12px;">
|
|
<table id="tbl">
|
|
<thead><tr>
|
|
<th>timestamp</th>
|
|
<th>down_90th</th>
|
|
<th>up_90th</th>
|
|
<th>latency</th>
|
|
<th>jitter</th>
|
|
<th>failed</th>
|
|
<th>isp</th>
|
|
<th>ip</th>
|
|
<th>location</th>
|
|
</tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="sentinel"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
async function loadSeries() {
|
|
const maxPoints = Math.max(1, Math.min({{ SERIES_MAX_POINTS_HARD }}, Number(document.getElementById('maxPoints').value||{{ SERIES_MAX_POINTS_DEFAULT }})));
|
|
const url = new URL('/api/series', window.location.origin);
|
|
url.searchParams.set('max_points', maxPoints);
|
|
const res = await fetch(url);
|
|
const js = await res.json();
|
|
|
|
const t = js.points.map(p => new Date(p.t * 1000));
|
|
const down = js.points.map(p => p.down_90th);
|
|
const up = js.points.map(p => p.up_90th);
|
|
|
|
const layout = {
|
|
margin: {l: 50, r: 20, t: 10, b: 40},
|
|
paper_bgcolor: '#12121a',
|
|
plot_bgcolor: '#12121a',
|
|
xaxis: {title: 'Time', gridcolor: '#23232b'},
|
|
yaxis: {title: 'Mbit/s (approx)', gridcolor: '#23232b'},
|
|
showlegend: true,
|
|
legend: {orientation: 'h'}
|
|
};
|
|
|
|
const traces = [
|
|
{x: t, y: down, name: 'down_90th', mode: 'lines', type: 'scatter'},
|
|
{x: t, y: up, name: 'up_90th', mode: 'lines', type: 'scatter'}
|
|
];
|
|
|
|
Plotly.react('chart', traces, layout, {displayModeBar: true, responsive: true});
|
|
document.getElementById('seriesMeta').textContent = `returned ${js.returned} / total ${js.count_total} (stride ${js.stride})`;
|
|
}
|
|
|
|
document.getElementById('reloadSeries').addEventListener('click', loadSeries);
|
|
window.addEventListener('load', loadSeries);
|
|
|
|
let nextCursor = null;
|
|
let loading = false;
|
|
|
|
function formatRow(r) {
|
|
const loc = `${r.location_code||''} · ${r.location_city||''} · ${r.location_region||''}`;
|
|
return `<tr>
|
|
<td title="${r.timestamp}">${r.timestamp_iso}</td>
|
|
<td>${r.down_90th ?? ''}</td>
|
|
<td>${r.up_90th ?? ''}</td>
|
|
<td>${r.latency ?? ''}</td>
|
|
<td>${r.jitter ?? ''}</td>
|
|
<td>${r.failed}</td>
|
|
<td>${r.isp ?? ''}</td>
|
|
<td style="max-width:220px; overflow:hidden; text-overflow:ellipsis;">${r.ip ?? ''}</td>
|
|
<td>${loc}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (loading) return;
|
|
loading = true;
|
|
|
|
const limit = Math.max(10, Math.min({{ PAGE_SIZE_MAX }}, Number(document.getElementById('pageSize').value||{{ PAGE_SIZE_DEFAULT }})));
|
|
const order = document.getElementById('orderSel').value;
|
|
|
|
const url = new URL('/api/table', window.location.origin);
|
|
url.searchParams.set('limit', limit);
|
|
url.searchParams.set('order', order);
|
|
if (nextCursor !== null) url.searchParams.set('cursor', nextCursor);
|
|
|
|
const res = await fetch(url);
|
|
const js = await res.json();
|
|
|
|
const tbody = document.querySelector('#tbl tbody');
|
|
tbody.insertAdjacentHTML('beforeend', js.rows.map(formatRow).join(''));
|
|
|
|
nextCursor = js.next_cursor;
|
|
document.getElementById('tableMeta').textContent = `loaded ${js.count} rows · next cursor: ${nextCursor ?? '—'}`;
|
|
|
|
loading = false;
|
|
}
|
|
|
|
function resetTable() {
|
|
document.querySelector('#tbl tbody').innerHTML = '';
|
|
nextCursor = null;
|
|
loadMore();
|
|
}
|
|
|
|
document.getElementById('resetTable').addEventListener('click', resetTable);
|
|
window.addEventListener('load', resetTable);
|
|
|
|
const sentinel = document.getElementById('sentinel');
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach(e => {
|
|
if (e.isIntersecting) loadMore();
|
|
});
|
|
}, {root: null, rootMargin: '200px', threshold: 0});
|
|
io.observe(sentinel);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|