updated the website
This commit is contained in:
@@ -1,217 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Internet Speed Dashboard</title>
|
||||
|
||||
<!-- DataTables CSS -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css"
|
||||
/>
|
||||
|
||||
<!-- jQuery + DataTables JS -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
||||
|
||||
<!-- Chart.js core (umd build) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js"></script>
|
||||
|
||||
<!-- date adapter (Moment.js + Chart.js adapter) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment/min/moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment/dist/chartjs-adapter-moment.min.js"></script>
|
||||
|
||||
<!-- zoom & pan plugin -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom/dist/chartjs-plugin-zoom.min.js"></script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Speed Tests</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
#speedChart { max-width: 100%; margin-bottom: 2rem; }
|
||||
table.dataTable { width: 100% !important; }
|
||||
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>
|
||||
<h1>90th-Percentile Speeds (Aggregated every {{ aggregation }})</h1>
|
||||
<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>
|
||||
|
||||
<!-- Chart -->
|
||||
<canvas id="speedChart" width="800" height="300"></canvas>
|
||||
<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="200" 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>
|
||||
var chartData = {{ chart_data | tojson }};
|
||||
async function loadSeries() {
|
||||
const maxPoints = Math.max(200, 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 acceptableDownloadRange = {
|
||||
min: 600,
|
||||
max: 1000
|
||||
};
|
||||
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 acceptableUploadRange = {
|
||||
min: 15,
|
||||
max: 50
|
||||
};
|
||||
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 times = chartData.times;
|
||||
const traces = [
|
||||
{x: t, y: down, name: 'down_90th', mode: 'lines', type: 'scatter'},
|
||||
{x: t, y: up, name: 'up_90th', mode: 'lines', type: 'scatter'}
|
||||
];
|
||||
|
||||
const downloadMinBand = times.map(() => acceptableDownloadRange.min);
|
||||
const downloadMaxBand = times.map(() => acceptableDownloadRange.max);
|
||||
const uploadMinBand = times.map(() => acceptableUploadRange.min);
|
||||
const uploadMaxBand = times.map(() => acceptableUploadRange.max);
|
||||
|
||||
// --- Build annotation boxes for NaN spans ---
|
||||
const annotations = [];
|
||||
let inGap = false;
|
||||
let gapStart = null;
|
||||
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
const down = chartData.down_90th[i];
|
||||
const up = chartData.up_90th[i];
|
||||
|
||||
const isNan = isNaN(down) || isNaN(up);
|
||||
|
||||
if (!inGap && isNan) {
|
||||
inGap = true;
|
||||
gapStart = times[i];
|
||||
}
|
||||
|
||||
if (inGap && !isNan) {
|
||||
const gapEnd = times[i];
|
||||
annotations.push({
|
||||
type: 'box',
|
||||
xMin: gapStart,
|
||||
xMax: gapEnd,
|
||||
backgroundColor: 'rgba(255, 0, 0, 0.15)',
|
||||
borderWidth: 0
|
||||
});
|
||||
inGap = false;
|
||||
}
|
||||
Plotly.react('chart', traces, layout, {displayModeBar: true, responsive: true});
|
||||
document.getElementById('seriesMeta').textContent = `returned ${js.returned} / total ${js.count_total} (stride ${js.stride})`;
|
||||
}
|
||||
|
||||
// Close trailing gap if it reaches the end
|
||||
if (inGap) {
|
||||
annotations.push({
|
||||
type: 'box',
|
||||
xMin: gapStart,
|
||||
xMax: times[times.length - 1],
|
||||
backgroundColor: 'rgba(255, 0, 0, 0.15)',
|
||||
borderWidth: 0
|
||||
});
|
||||
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>`;
|
||||
}
|
||||
|
||||
var ctx = document.getElementById('speedChart').getContext('2d');
|
||||
var speedChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: times,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Download Speed',
|
||||
data: chartData.down_90th,
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Upload Speed',
|
||||
data: chartData.up_90th,
|
||||
borderColor: 'rgba(153, 102, 255, 1)',
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Acceptable Download Range',
|
||||
data: downloadMinBand,
|
||||
borderColor: 'rgba(0, 200, 0, 0.1)',
|
||||
backgroundColor: 'rgba(0, 200, 0, 0.1)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
borderWidth: 0
|
||||
},
|
||||
{
|
||||
data: downloadMaxBand,
|
||||
borderColor: 'rgba(0, 200, 0, 0.1)',
|
||||
backgroundColor: 'rgba(0, 200, 0, 0.1)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
borderWidth: 0
|
||||
},
|
||||
{
|
||||
label: 'Acceptable Upload Range',
|
||||
data: uploadMinBand,
|
||||
borderColor: 'rgba(255, 165, 0, 0.1)',
|
||||
backgroundColor: 'rgba(255, 165, 0, 0.1)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
borderWidth: 0
|
||||
},
|
||||
{
|
||||
data: uploadMaxBand,
|
||||
borderColor: 'rgba(255, 165, 0, 0.1)',
|
||||
backgroundColor: 'rgba(255, 165, 0, 0.1)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
filter: function(item) {
|
||||
return item.text !== undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: annotations
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Speed (Mbps)'
|
||||
},
|
||||
min: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
async function loadMore() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
|
||||
<!-- Data table -->
|
||||
<h2>Raw Data</h2>
|
||||
<table id="speedTable" class="display">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recorded At</th>
|
||||
<th>Download (90th)</th>
|
||||
<th>Upload (90th)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data %}
|
||||
<tr>
|
||||
<td>{{ row.datetime }}</td>
|
||||
<td>{{ row.down_90th }}</td>
|
||||
<td>{{ row.up_90th }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
const limit = Math.max(10, Math.min({{ PAGE_SIZE_MAX }}, Number(document.getElementById('pageSize').value||{{ PAGE_SIZE_DEFAULT }})));
|
||||
const order = document.getElementById('orderSel').value;
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#speedTable').DataTable({
|
||||
pageLength: 50,
|
||||
order: [[0, 'desc']]
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Graph: Download vs. Upload Speeds</h2>
|
||||
<canvas id="speedChart"></canvas>
|
||||
|
||||
<h2>Test Results</h2>
|
||||
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>ISP</th>
|
||||
<th>Latency</th>
|
||||
<th>Jitter</th>
|
||||
<th>Download (90th Percentile)</th>
|
||||
<th>Upload (90th Percentile)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data|reverse %}
|
||||
<tr>
|
||||
<td>{{ row.timestamp }}</td>
|
||||
<td>{{ row.isp }}</td>
|
||||
<td>{{ row.latency }}</td>
|
||||
<td>{{ row.jitter }}</td>
|
||||
<td>{{ row.down_90th }}</td>
|
||||
<td>{{ row.up_90th }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user