stats-site: convert speedtest page to static HTML to match build.sh pipeline
This commit is contained in:
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
title: Speedtest
|
|
||||||
date: 2026-05-09
|
|
||||||
---
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
{{ define "main" }}
|
|
||||||
<section class="speedtest">
|
|
||||||
<h1>Speedtest results</h1>
|
|
||||||
<p class="muted">Updated <span id="ts">…</span>. Retention 30 days.</p>
|
|
||||||
|
|
||||||
<h2>Last 24h / 7d / 30d (median)</h2>
|
|
||||||
<table class="agg">
|
|
||||||
<thead><tr><th>Node</th><th>Period</th><th>Count</th><th>↓ Mbps</th><th>↑ Mbps</th><th>Ping ms</th></tr></thead>
|
|
||||||
<tbody id="agg-tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Recent tests (last 200)</h2>
|
|
||||||
<div class="filters">
|
|
||||||
Node:
|
|
||||||
<select id="filter-node">
|
|
||||||
<option value="">all</option>
|
|
||||||
<option value="fl">fl</option>
|
|
||||||
<option value="pl1">pl1</option>
|
|
||||||
</select>
|
|
||||||
Country:
|
|
||||||
<input type="text" id="filter-country" maxlength="2" placeholder="RU" style="width: 4rem;">
|
|
||||||
</div>
|
|
||||||
<table class="recent">
|
|
||||||
<thead><tr>
|
|
||||||
<th>Time</th><th>Node</th><th>Country</th>
|
|
||||||
<th>↓ Mbps</th><th>↑ Mbps</th><th>Ping ms</th><th>Jitter ms</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="recent-tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function clearChildren(el) {
|
|
||||||
while (el.firstChild) el.removeChild(el.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCell(row, text) {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.textContent = text;
|
|
||||||
row.appendChild(td);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNumber(n, fixed) {
|
|
||||||
if (typeof n !== 'number' || !isFinite(n)) return '0';
|
|
||||||
return n.toFixed(fixed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTimestamp(unixSeconds) {
|
|
||||||
if (typeof unixSeconds !== 'number') return '';
|
|
||||||
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAggTable(snapshot) {
|
|
||||||
const aggBody = document.getElementById('agg-tbody');
|
|
||||||
clearChildren(aggBody);
|
|
||||||
const periods = [
|
|
||||||
{ key: 'agg_day', label: '24h' },
|
|
||||||
{ key: 'agg_week', label: '7d' },
|
|
||||||
{ key: 'agg_month', label: '30d' },
|
|
||||||
];
|
|
||||||
for (const { key, label } of periods) {
|
|
||||||
const m = snapshot[key] || {};
|
|
||||||
for (const node of ['fl', 'pl1']) {
|
|
||||||
const a = m[node];
|
|
||||||
if (!a) continue;
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
appendCell(tr, node);
|
|
||||||
appendCell(tr, label);
|
|
||||||
appendCell(tr, String(a.count | 0));
|
|
||||||
appendCell(tr, fmtNumber(a.median_dl, 1));
|
|
||||||
appendCell(tr, fmtNumber(a.median_ul, 1));
|
|
||||||
appendCell(tr, fmtNumber(a.median_ping, 1));
|
|
||||||
aggBody.appendChild(tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecentTable(rows) {
|
|
||||||
const body = document.getElementById('recent-tbody');
|
|
||||||
clearChildren(body);
|
|
||||||
const limited = rows.slice(0, 200);
|
|
||||||
for (const r of limited) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
appendCell(tr, fmtTimestamp(r.Timestamp));
|
|
||||||
appendCell(tr, String(r.Node || ''));
|
|
||||||
appendCell(tr, String(r.Country || ''));
|
|
||||||
appendCell(tr, fmtNumber(r.DlMbps, 1));
|
|
||||||
appendCell(tr, fmtNumber(r.UlMbps, 1));
|
|
||||||
appendCell(tr, fmtNumber(r.PingMs, 1));
|
|
||||||
appendCell(tr, fmtNumber(r.JitterMs, 1));
|
|
||||||
body.appendChild(tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSnapshot() {
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/data/snapshot.json', { cache: 'no-store' });
|
|
||||||
snapshot = await r.json();
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('ts').textContent = 'load error';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const st = snapshot.speedtest;
|
|
||||||
if (!st) {
|
|
||||||
document.getElementById('ts').textContent = 'no data';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('ts').textContent = fmtTimestamp(st.generated_at);
|
|
||||||
renderAggTable(st);
|
|
||||||
|
|
||||||
const recent = Array.isArray(st.recent) ? st.recent : [];
|
|
||||||
const filterNode = document.getElementById('filter-node');
|
|
||||||
const filterCountry = document.getElementById('filter-country');
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
const fn = filterNode.value;
|
|
||||||
const fc = filterCountry.value.toUpperCase();
|
|
||||||
const filtered = recent.filter(row =>
|
|
||||||
(!fn || row.Node === fn) &&
|
|
||||||
(!fc || row.Country === fc));
|
|
||||||
renderRecentTable(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
filterNode.addEventListener('change', applyFilters);
|
|
||||||
filterCountry.addEventListener('input', applyFilters);
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSnapshot();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.speedtest table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
||||||
.speedtest th, .speedtest td { padding: 0.4rem 0.7rem; border-bottom: 1px solid #333; text-align: left; }
|
|
||||||
.speedtest .muted { opacity: 0.6; font-size: 0.9rem; }
|
|
||||||
.speedtest .filters { margin: 1rem 0; }
|
|
||||||
</style>
|
|
||||||
{{ end }}
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Speedtest — PSP Speedtest Stats</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
.speedtest table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.speedtest th,
|
||||||
|
.speedtest td {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.speedtest .muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.speedtest .filters {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.speedtest .filters select,
|
||||||
|
.speedtest .filters input {
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.speedtest h2 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Speedtest</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="speedtest">
|
||||||
|
<p class="muted">Updated <span id="ts">…</span>. Retention 30 days.</p>
|
||||||
|
|
||||||
|
<h2>Last 24h / 7d / 30d (median)</h2>
|
||||||
|
<table class="agg">
|
||||||
|
<thead><tr><th>Node</th><th>Period</th><th>Count</th><th>↓ Mbps</th><th>↑ Mbps</th><th>Ping ms</th></tr></thead>
|
||||||
|
<tbody id="agg-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Recent tests (last 200)</h2>
|
||||||
|
<div class="filters">
|
||||||
|
Node:
|
||||||
|
<select id="filter-node">
|
||||||
|
<option value="">all</option>
|
||||||
|
<option value="fl">fl</option>
|
||||||
|
<option value="pl1">pl1</option>
|
||||||
|
</select>
|
||||||
|
Country:
|
||||||
|
<input type="text" id="filter-country" maxlength="2" placeholder="RU" style="width: 4rem;">
|
||||||
|
</div>
|
||||||
|
<table class="recent">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Time</th><th>Node</th><th>Country</th>
|
||||||
|
<th>↓ Mbps</th><th>↑ Mbps</th><th>Ping ms</th><th>Jitter ms</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="recent-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="footer-nav">
|
||||||
|
<a href="/">← Назад</a>
|
||||||
|
<a href="/about/">About</a>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function clearChildren(el) {
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCell(row, text) {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.textContent = text;
|
||||||
|
row.appendChild(td);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNumber(n, fixed) {
|
||||||
|
if (typeof n !== 'number' || !isFinite(n)) return '0';
|
||||||
|
return n.toFixed(fixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTimestamp(unixSeconds) {
|
||||||
|
if (typeof unixSeconds !== 'number') return '';
|
||||||
|
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAggTable(snapshot) {
|
||||||
|
const aggBody = document.getElementById('agg-tbody');
|
||||||
|
clearChildren(aggBody);
|
||||||
|
const periods = [
|
||||||
|
{ key: 'agg_day', label: '24h' },
|
||||||
|
{ key: 'agg_week', label: '7d' },
|
||||||
|
{ key: 'agg_month', label: '30d' },
|
||||||
|
];
|
||||||
|
for (const { key, label } of periods) {
|
||||||
|
const m = snapshot[key] || {};
|
||||||
|
for (const node of ['fl', 'pl1']) {
|
||||||
|
const a = m[node];
|
||||||
|
if (!a) continue;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
appendCell(tr, node);
|
||||||
|
appendCell(tr, label);
|
||||||
|
appendCell(tr, String(a.count | 0));
|
||||||
|
appendCell(tr, fmtNumber(a.median_dl, 1));
|
||||||
|
appendCell(tr, fmtNumber(a.median_ul, 1));
|
||||||
|
appendCell(tr, fmtNumber(a.median_ping, 1));
|
||||||
|
aggBody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentTable(rows) {
|
||||||
|
const body = document.getElementById('recent-tbody');
|
||||||
|
clearChildren(body);
|
||||||
|
const limited = rows.slice(0, 200);
|
||||||
|
for (const r of limited) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
appendCell(tr, fmtTimestamp(r.Timestamp));
|
||||||
|
appendCell(tr, String(r.Node || ''));
|
||||||
|
appendCell(tr, String(r.Country || ''));
|
||||||
|
appendCell(tr, fmtNumber(r.DlMbps, 1));
|
||||||
|
appendCell(tr, fmtNumber(r.UlMbps, 1));
|
||||||
|
appendCell(tr, fmtNumber(r.PingMs, 1));
|
||||||
|
appendCell(tr, fmtNumber(r.JitterMs, 1));
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshot() {
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/data/snapshot.json', { cache: 'no-store' });
|
||||||
|
snapshot = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('ts').textContent = 'load error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const st = snapshot.speedtest;
|
||||||
|
if (!st) {
|
||||||
|
document.getElementById('ts').textContent = 'no data';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ts').textContent = fmtTimestamp(st.generated_at);
|
||||||
|
renderAggTable(st);
|
||||||
|
|
||||||
|
const recent = Array.isArray(st.recent) ? st.recent : [];
|
||||||
|
const filterNode = document.getElementById('filter-node');
|
||||||
|
const filterCountry = document.getElementById('filter-country');
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const fn = filterNode.value;
|
||||||
|
const fc = filterCountry.value.toUpperCase();
|
||||||
|
const filtered = recent.filter(row =>
|
||||||
|
(!fn || row.Node === fn) &&
|
||||||
|
(!fc || row.Country === fc));
|
||||||
|
renderRecentTable(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterNode.addEventListener('change', applyFilters);
|
||||||
|
filterCountry.addEventListener('input', applyFilters);
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSnapshot();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user