Switch worker to the Ookla CLI (jitter, packet loss, server, ISP, shareable result URL, bytes). Migration 028 enriches speedtest_results + adds a generic app_settings store. New /speedtest page: KPIs, throughput + latency charts, window stats, configurable schedule (reschedulable cron) & low-speed alert threshold, history table. SV card gains ping/jitter + a link through to the page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
8.4 KiB
JavaScript
186 lines
8.4 KiB
JavaScript
import { el, mount, safeHref } from '../dom.js';
|
|
import { api } from '../api.js';
|
|
|
|
const SVG = 'http://www.w3.org/2000/svg';
|
|
const RANGES = [{ h: 24, l: '24h' }, { h: 168, l: '7d' }, { h: 720, l: '30d' }];
|
|
const INTERVALS = [
|
|
{ v: 15, l: 'Every 15 min' }, { v: 30, l: 'Every 30 min' }, { v: 60, l: 'Hourly' },
|
|
{ v: 120, l: 'Every 2 h' }, { v: 360, l: 'Every 6 h' }, { v: 720, l: 'Every 12 h' },
|
|
{ v: 1440, l: 'Daily' }
|
|
];
|
|
|
|
let hours = 168;
|
|
let timer = null;
|
|
|
|
const n1 = v => v == null ? '—' : Number(v).toFixed(1);
|
|
const n0 = v => v == null ? '—' : Math.round(Number(v));
|
|
function ago(ts) {
|
|
if (!ts) return '—';
|
|
const s = (Date.now() - new Date(ts).getTime()) / 1000;
|
|
if (s < 90) return Math.round(s) + 's ago';
|
|
if (s < 5400) return Math.round(s / 60) + 'm ago';
|
|
if (s < 172800) return Math.round(s / 3600) + 'h ago';
|
|
return Math.round(s / 86400) + 'd ago';
|
|
}
|
|
|
|
function mkSvg(tag, attrs) {
|
|
const e = document.createElementNS(SVG, tag);
|
|
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
|
return e;
|
|
}
|
|
|
|
// Multi-series line chart over a shared time axis. rows ascending by ran_at.
|
|
function chart(rows, series, h = 170) {
|
|
if (!rows.length) return el('div', { class: 'muted', style: { padding: '24px 0' } }, 'No data in this window.');
|
|
const W = 1000, H = h, padL = 42, padR = 10, padT = 10, padB = 18;
|
|
const xs = rows.map(r => new Date(r.ran_at).getTime());
|
|
const x0 = xs[0], x1 = xs[xs.length - 1] || x0 + 1;
|
|
let vmax = 1;
|
|
series.forEach(s => rows.forEach(r => { if (r[s.key] != null) vmax = Math.max(vmax, Number(r[s.key])); }));
|
|
vmax *= 1.12;
|
|
const X = t => padL + (x1 === x0 ? 0 : (t - x0) / (x1 - x0)) * (W - padL - padR);
|
|
const Y = v => H - padB - (v / vmax) * (H - padT - padB);
|
|
const svg = mkSvg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'st-chart', preserveAspectRatio: 'none' });
|
|
[0, 0.5, 1].forEach(f => {
|
|
const y = Y(vmax * f);
|
|
svg.appendChild(mkSvg('line', { x1: padL, x2: W - padR, y1: y, y2: y, stroke: '#ffffff10' }));
|
|
const t = mkSvg('text', { x: 4, y: y + 3, fill: '#888094', 'font-size': 11 });
|
|
t.textContent = Math.round(vmax * f); svg.appendChild(t);
|
|
});
|
|
series.forEach(s => {
|
|
const pts = rows.filter(r => r[s.key] != null)
|
|
.map(r => `${X(new Date(r.ran_at).getTime())},${Y(Number(r[s.key]))}`).join(' ');
|
|
if (pts) svg.appendChild(mkSvg('polyline',
|
|
{ points: pts, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round' }));
|
|
});
|
|
return svg;
|
|
}
|
|
|
|
function legend(series) {
|
|
return el('div', { class: 'st-legend' }, series.map(s =>
|
|
el('span', { class: 'st-leg' }, el('i', { style: { background: s.color } }), s.label)));
|
|
}
|
|
|
|
function kpi(label, value, sub, cls) {
|
|
return el('div', { class: 'st-kpi' + (cls ? ' ' + cls : '') },
|
|
el('span', { class: 'st-kpi-l' }, label),
|
|
el('span', { class: 'st-kpi-v' }, value),
|
|
sub ? el('span', { class: 'st-kpi-s' }, sub) : null);
|
|
}
|
|
|
|
async function load(main) {
|
|
let latest, statsd, rows, cfg;
|
|
try {
|
|
[latest, statsd, rows, cfg] = await Promise.all([
|
|
api.get('/api/speedtest/latest'),
|
|
api.get('/api/speedtest/stats?hours=' + hours),
|
|
api.get('/api/speedtest/results?hours=' + hours),
|
|
api.get('/api/speedtest/config')
|
|
]);
|
|
} catch { mount(main, el('span', { class: 'muted' }, 'Speedtest data unavailable')); return; }
|
|
|
|
const thr = Number(cfg.threshold_down_mbps) || 0;
|
|
const lowNow = latest && thr > 0 && Number(latest.down_mbps) < thr;
|
|
|
|
const rangeBtns = el('div', { class: 'st-ranges' }, RANGES.map(r =>
|
|
el('button', { class: 'st-range' + (r.h === hours ? ' on' : ''), onclick: () => { hours = r.h; load(main); } }, r.l)));
|
|
|
|
const runBtn = el('button', { class: 'primary' }, 'Run now');
|
|
runBtn.onclick = async () => {
|
|
runBtn.disabled = true; runBtn.textContent = 'Running…';
|
|
try { await api.post('/api/speedtest/run', {}); } catch {}
|
|
setTimeout(() => load(main), 35000);
|
|
};
|
|
|
|
// schedule + threshold form
|
|
const intSel = el('select', { class: 'pm-input' }, INTERVALS.map(i =>
|
|
el('option', { value: String(i.v) }, i.l)));
|
|
intSel.value = String(cfg.interval_min);
|
|
const thrIn = el('input', { class: 'pm-input', type: 'number', min: '0', step: '10', value: String(thr), style: { maxWidth: '110px' } });
|
|
const saveBtn = el('button', {}, 'Save');
|
|
const saveOut = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
|
saveBtn.onclick = async () => {
|
|
try {
|
|
await api.put('/api/speedtest/config', {
|
|
interval_min: Number(intSel.value), threshold_down_mbps: Number(thrIn.value) || 0
|
|
});
|
|
saveOut.textContent = 'Saved'; setTimeout(() => load(main), 500);
|
|
} catch { saveOut.textContent = 'Failed'; }
|
|
};
|
|
|
|
mount(main,
|
|
el('div', { class: 'st-head' },
|
|
el('div', {},
|
|
el('h1', { class: 'view-h1', style: { margin: 0 } }, 'Speedtest'),
|
|
el('p', { class: 'view-sub', style: { margin: '2px 0 0' } },
|
|
'Automated Ookla speed tests — history, trends & schedule.')),
|
|
el('div', { class: 'st-actions' }, rangeBtns, runBtn)),
|
|
|
|
// latest snapshot
|
|
el('div', { class: 'st-kpis' },
|
|
kpi('Download', latest ? n0(latest.down_mbps) : '—', 'Mbps', lowNow ? 'bad' : 'down'),
|
|
kpi('Upload', latest ? n0(latest.up_mbps) : '—', 'Mbps', 'up'),
|
|
kpi('Ping', latest ? n1(latest.ping_ms) : '—', 'ms'),
|
|
kpi('Jitter', latest ? n1(latest.jitter_ms) : '—', 'ms'),
|
|
kpi('Packet loss', latest ? n1(latest.packet_loss) : '—', '%'),
|
|
kpi('Last run', ago(latest && latest.ran_at), latest && latest.isp ? latest.isp : '')),
|
|
|
|
latest ? el('div', { class: 'st-meta muted' },
|
|
latest.server_name ? `Server: ${latest.server_name}` : '',
|
|
latest.result_url ? el('a', { href: safeHref(latest.result_url), target: '_blank', rel: 'noopener', class: 'st-link' }, ' view result ↗') : null,
|
|
lowNow ? el('span', { class: 'st-warn' }, ` ⚠ below ${thr} Mbps threshold`) : null) : null,
|
|
|
|
// stats for the window
|
|
el('div', { class: 'st-stats' },
|
|
el('span', {}, `Avg ↓ ${n0(statsd.avg_down)}`),
|
|
el('span', {}, `min ${n0(statsd.min_down)} · max ${n0(statsd.max_down)}`),
|
|
el('span', {}, `Avg ↑ ${n0(statsd.avg_up)}`),
|
|
el('span', {}, `Avg ping ${n1(statsd.avg_ping)} ms`),
|
|
el('span', {}, `${n0(statsd.n)} tests`),
|
|
Number(statsd.failures) ? el('span', { class: 'st-warn' }, `${statsd.failures} failed`) : null),
|
|
|
|
// charts
|
|
el('h2', { class: 'st-h2' }, 'Throughput'),
|
|
legend([{ color: 'var(--accent)', label: 'Download' }, { color: '#6fa86a', label: 'Upload' }]),
|
|
chart(rows, [{ key: 'down_mbps', color: 'var(--accent)' }, { key: 'up_mbps', color: '#6fa86a' }], 180),
|
|
el('h2', { class: 'st-h2' }, 'Latency'),
|
|
legend([{ color: '#d4a04a', label: 'Ping (ms)' }, { color: '#7a7390', label: 'Jitter (ms)' }]),
|
|
chart(rows, [{ key: 'ping_ms', color: '#d4a04a' }, { key: 'jitter_ms', color: '#7a7390' }], 130),
|
|
|
|
// schedule
|
|
el('div', { class: 'card st-card' },
|
|
el('h3', {}, 'Schedule & alert'),
|
|
el('div', { class: 'st-form' },
|
|
el('label', { class: 'st-lbl' }, 'Run', intSel),
|
|
el('label', { class: 'st-lbl' }, 'Alert if ↓ below (Mbps)', thrIn),
|
|
saveBtn, saveOut)),
|
|
|
|
// history table
|
|
el('h2', { class: 'st-h2' }, 'History'),
|
|
el('div', { class: 'st-table-wrap' },
|
|
el('table', { class: 'st-table' },
|
|
el('thead', {}, el('tr', {},
|
|
...['Time', 'Down', 'Up', 'Ping', 'Jitter', 'Loss', 'Server', ''].map(h => el('th', {}, h)))),
|
|
el('tbody', {}, [...rows].reverse().slice(0, 100).map(r =>
|
|
el('tr', { class: r.ok ? '' : 'st-fail' },
|
|
el('td', {}, new Date(r.ran_at).toLocaleString('en-AU', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
|
el('td', { class: 'num' }, r.ok ? n0(r.down_mbps) : '✕'),
|
|
el('td', { class: 'num' }, r.ok ? n0(r.up_mbps) : ''),
|
|
el('td', { class: 'num' }, n1(r.ping_ms)),
|
|
el('td', { class: 'num' }, n1(r.jitter_ms)),
|
|
el('td', { class: 'num' }, r.packet_loss == null ? '—' : n1(r.packet_loss) + '%'),
|
|
el('td', { class: 'muted' }, r.server_name || ''),
|
|
el('td', {}, r.result_url ? el('a', { href: safeHref(r.result_url), target: '_blank', rel: 'noopener', class: 'st-link' }, '↗') : ''))))))
|
|
);
|
|
}
|
|
|
|
export async function render(main) {
|
|
hours = hours || 168;
|
|
await load(main);
|
|
if (timer) clearInterval(timer);
|
|
timer = setInterval(() => {
|
|
if (!document.querySelector('.st-kpis')) { clearInterval(timer); timer = null; return; }
|
|
load(main);
|
|
}, 30000);
|
|
}
|