updates people updates

This commit is contained in:
2026-02-15 17:31:33 +01:00
parent cde554c981
commit 8a0e48b3cb
5 changed files with 441 additions and 302 deletions

View File

@@ -1,171 +1,232 @@
<!doctype 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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speed Test Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<style>
body { font-family: sans-serif; padding: 20px; background: #f4f4f9; }
.controls { background: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; gap: 10px; align-items: center;}
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 20px; }
.card { background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { margin: 0 0 10px 0; color: #666; font-size: 0.9em; text-transform: uppercase; }
.card .value { font-size: 1.8em; font-weight: bold; color: #333; }
.chart-container { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; height: 400px; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #007bff; color: white; }
.failed-row { background-color: #ffe6e6; color: #b30000; }
</style>
</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 class="controls">
<label>From: <input type="date" id="startDate"></label>
<label>To: <input type="date" id="endDate"></label>
<button onclick="fetchData()">Update Dashboard</button>
</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>
<div class="cards">
<div class="card">
<h3>Uptime</h3>
<div class="value" id="uptimeVal">--%</div>
</div>
<div class="card">
<h3>Avg Download</h3>
<div class="value" id="avgDownVal">-- Mbps</div>
</div>
<div class="card">
<h3>Avg Upload</h3>
<div class="value" id="avgUpVal">-- Mbps</div>
</div>
</div>
<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>
<div class="chart-container">
<canvas id="speedChart"></canvas>
</div>
<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();
<div class="chart-container" style="margin-top: 20px;">
<h3>Average Speed by Hour of Day (0-24h)</h3>
<canvas id="hourlyChart"></canvas>
</div>
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);
<h3>Detailed Logs</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>ISP</th>
<th>Download (90th)</th>
<th>Upload (90th)</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody id="tableBody">
</tbody>
</table>
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'}
};
<script>
let chartInstance = null;
const traces = [
{x: t, y: down, name: 'down_90th', mode: 'lines', type: 'scatter'},
{x: t, y: up, name: 'up_90th', mode: 'lines', type: 'scatter'}
];
async function fetchData() {
const start = document.getElementById('startDate').value;
const end = document.getElementById('endDate').value;
let url = '/api/data';
if(start && end) {
url += `?start=${start}&end=${end}`;
}
Plotly.react('chart', traces, layout, {displayModeBar: true, responsive: true});
document.getElementById('seriesMeta').textContent = `returned ${js.returned} / total ${js.count_total} (stride ${js.stride})`;
}
const response = await fetch(url);
const data = await response.json();
document.getElementById('reloadSeries').addEventListener('click', loadSeries);
window.addEventListener('load', loadSeries);
updateStats(data.stats);
updateChart(data.rows);
updateTable(data.rows);
}
let nextCursor = null;
let loading = false;
function updateStats(stats) {
document.getElementById('uptimeVal').innerText = stats.uptime + '%';
document.getElementById('avgDownVal').innerText = stats.avg_down + ' Mbps';
document.getElementById('avgUpVal').innerText = stats.avg_up + ' Mbps';
}
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>`;
}
function updateTable(rows) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
// Show last 50 rows for performance (reverse order)
const recentRows = rows.slice().reverse().slice(0, 100);
async function loadMore() {
if (loading) return;
loading = true;
recentRows.forEach(row => {
const tr = document.createElement('tr');
if(row.failed) tr.classList.add('failed-row');
tr.innerHTML = `
<td>${row.readable_date}</td>
<td>${row.isp || 'N/A'}</td>
<td>${row.down ? row.down.toFixed(2) : '-'}</td>
<td>${row.up ? row.up.toFixed(2) : '-'}</td>
<td>${row.latency ? row.latency.toFixed(2) + 'ms' : '-'}</td>
<td>${row.failed ? 'FAILED' : 'OK'}</td>
`;
tbody.appendChild(tr);
});
}
const limit = Math.max(10, Math.min({{ PAGE_SIZE_MAX }}, Number(document.getElementById('pageSize').value||{{ PAGE_SIZE_DEFAULT }})));
const order = document.getElementById('orderSel').value;
function updateChart(rows) {
const ctx = document.getElementById('speedChart').getContext('2d');
// Prepare data for Chart.js
const downData = rows.filter(r => !r.failed).map(r => ({x: r.timestamp, y: r.down}));
const upData = rows.filter(r => !r.failed).map(r => ({x: r.timestamp, y: r.up}));
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);
if (chartInstance) {
chartInstance.destroy();
}
const res = await fetch(url);
const js = await res.json();
chartInstance = new Chart(ctx, {
type: 'line',
data: {
datasets: [
{
label: 'Download (90th)',
data: downData,
borderColor: '#007bff',
tension: 0.1
},
{
label: 'Upload (90th)',
data: upData,
borderColor: '#28a745',
tension: 0.1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
time: { unit: 'day' },
title: { display: true, text: 'Date' }
},
y: {
title: { display: true, text: 'Speed (Mbps)' },
beginAtZero: true
}
}
}
});
}
const tbody = document.querySelector('#tbl tbody');
tbody.insertAdjacentHTML('beforeend', js.rows.map(formatRow).join(''));
// Set default dates (past 7 days) and load
window.onload = function() {
const today = new Date();
const lastWeek = new Date();
lastWeek.setDate(today.getDate() - 7);
document.getElementById('endDate').valueAsDate = today;
document.getElementById('startDate').valueAsDate = lastWeek;
fetchData();
fetchHourlyData();
};
nextCursor = js.next_cursor;
document.getElementById('tableMeta').textContent = `loaded ${js.count} rows · next cursor: ${nextCursor ?? '—'}`;
async function fetchHourlyData() {
const response = await fetch('/api/hourly_stats');
const data = await response.json();
const ctx = document.getElementById('hourlyChart').getContext('2d');
new Chart(ctx, {
type: 'bar', // Bar chart is better for comparing buckets
data: {
labels: data.map(d => d.hour + ":00"),
datasets: [
{
label: 'Avg Download (Mbps)',
data: data.map(d => d.down),
backgroundColor: 'rgba(0, 123, 255, 0.7)',
},
{
label: 'Avg Upload (Mbps)',
data: data.map(d => d.up),
backgroundColor: 'rgba(40, 167, 69, 0.7)',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Mbps' } }
},
plugins: {
annotation: {
annotations: {
box1: {
// Highlight the 1pm - 11pm danger zone
type: 'box',
xMin: 13,
xMax: 23,
backgroundColor: 'rgba(255, 99, 132, 0.1)',
borderWidth: 0,
label: { content: 'Problem Area', enabled: true }
}
}
}
}
}
});
}
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>
// Call this function in window.onload along with your existing fetchData()
// window.onload = function() { ... fetchHourlyData(); ... }
</script>
</body>
</html>