Files
speed-logger/templates/index.html
2025-08-26 15:07:55 +02:00

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>