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); }