updates people updates
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user