diff --git a/lib/api/routes/speedtest.js b/lib/api/routes/speedtest.js index f79f098..87cfed2 100644 --- a/lib/api/routes/speedtest.js +++ b/lib/api/routes/speedtest.js @@ -1,11 +1,39 @@ import { Router } from 'express'; +import { z } from 'zod'; import { asyncWrap } from '../errors.js'; import { requireOwner } from '../cap.js'; +import { validate } from '../validate.js'; import * as repo from '../../db/repos/speedtest.js'; +import * as settings from '../../db/repos/app_settings.js'; import { enqueue } from '../../jobs/queue.js'; +import { setSpeedtestSchedule } from '../../cron/index.js'; export const router = Router(); -router.get('/history', asyncWrap(async (_req, res) => res.json(await repo.history(30)))); -router.post('/run', requireOwner, asyncWrap(async (_req, res) => { - const id = await enqueue('speedtest', {}); - res.status(202).json({ enqueued: id }); + +const DEFAULT_CFG = { interval_min: 60, threshold_down_mbps: 0 }; +async function getCfg() { return { ...DEFAULT_CFG, ...(await settings.get('speedtest', {})) }; } + +router.get('/history', asyncWrap(async (req, res) => + res.json(await repo.history(Math.min(500, Number(req.query.limit) || 30))))); + +router.get('/results', asyncWrap(async (req, res) => + res.json(await repo.range(Math.min(2160, Number(req.query.hours) || 168), 2000)))); + +router.get('/latest', asyncWrap(async (_req, res) => res.json(await repo.latest()))); + +router.get('/stats', asyncWrap(async (req, res) => + res.json(await repo.stats(Math.min(2160, Number(req.query.hours) || 24))))); + +router.get('/config', asyncWrap(async (_req, res) => res.json(await getCfg()))); + +const cfgBody = z.object({ + interval_min: z.number().int().min(5).max(1440), + threshold_down_mbps: z.number().min(0).max(100000).default(0) +}); +router.put('/config', requireOwner, validate({ body: cfgBody }), asyncWrap(async (req, res) => { + const cfg = await settings.set('speedtest', req.body); + setSpeedtestSchedule(cfg.interval_min); + res.json(cfg); })); + +router.post('/run', requireOwner, asyncWrap(async (_req, res) => + res.status(202).json({ enqueued: await enqueue('speedtest', {}) }))); diff --git a/lib/cron/index.js b/lib/cron/index.js index bd4afcc..3aa34b7 100644 --- a/lib/cron/index.js +++ b/lib/cron/index.js @@ -6,6 +6,26 @@ import { checkAll } from '../health/checker.js'; import * as statusRepo from '../db/repos/service_status.js'; import * as services from '../db/repos/monitored_services.js'; import { runDeviceScanCycle } from '../infra/scan_cycle.js'; +import * as settings from '../db/repos/app_settings.js'; + +// Speedtest runs on a user-configurable interval (PUT /api/speedtest/config → +// setSpeedtestSchedule). Held module-level so it can be stopped + rescheduled. +let speedtestTask = null; +function speedtestExpr(min) { + if (min < 60) return `*/${min} * * * *`; + if (min % 60 === 0) { const h = min / 60; return h >= 24 ? '0 2 * * *' : `0 */${h} * * *`; } + return '0 * * * *'; +} +export function setSpeedtestSchedule(min) { + const m = Math.max(5, Math.min(1440, Number(min) || 60)); + if (speedtestTask) { speedtestTask.stop(); speedtestTask = null; } + const expr = speedtestExpr(m); + speedtestTask = cron.schedule(expr, async () => { + try { await enqueue('speedtest', {}); log.info({ expr }, 'cron speedtest enqueued'); } + catch (e) { log.error({ err: e }, 'cron speedtest failed'); } + }); + log.info({ expr, min: m }, 'speedtest schedule set'); +} export function startCron() { // Daily at 03:00 local time @@ -18,11 +38,10 @@ export function startCron() { } }); - // Hourly speedtest - cron.schedule('0 * * * *', async () => { - try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); } - catch (e) { log.error({ err: e }, 'cron speedtest failed'); } - }); + // Speedtest — interval from the saved config (default 60 min), reschedulable. + settings.get('speedtest', {}) + .then(cfg => setSpeedtestSchedule(cfg?.interval_min || 60)) + .catch(e => { log.error({ err: e }, 'speedtest schedule init failed'); setSpeedtestSchedule(60); }); // Health checks every minute. NOTE: this runs checkAll() inline; the same // probe+upsert logic is also exposed on-demand via the `health.check` pg-boss diff --git a/lib/db/migrations/028_speedtest_metrics.sql b/lib/db/migrations/028_speedtest_metrics.sql new file mode 100644 index 0000000..d4bb9cb --- /dev/null +++ b/lib/db/migrations/028_speedtest_metrics.sql @@ -0,0 +1,22 @@ +-- 028_speedtest_metrics.sql +-- Enrich speedtest results with the full Ookla metric set + a generic settings +-- store (reused by the speedtest schedule and, later, theming). +ALTER TABLE speedtest_results ALTER COLUMN down_mbps DROP NOT NULL; +ALTER TABLE speedtest_results ALTER COLUMN up_mbps DROP NOT NULL; +ALTER TABLE speedtest_results + ADD COLUMN IF NOT EXISTS jitter_ms numeric, + ADD COLUMN IF NOT EXISTS packet_loss numeric, + ADD COLUMN IF NOT EXISTS server_name text, + ADD COLUMN IF NOT EXISTS server_id text, + ADD COLUMN IF NOT EXISTS isp text, + ADD COLUMN IF NOT EXISTS result_url text, + ADD COLUMN IF NOT EXISTS down_bytes bigint, + ADD COLUMN IF NOT EXISTS up_bytes bigint, + ADD COLUMN IF NOT EXISTS ok boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS error text; + +CREATE TABLE IF NOT EXISTS app_settings ( + key text PRIMARY KEY, + value jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/lib/db/repos/app_settings.js b/lib/db/repos/app_settings.js new file mode 100644 index 0000000..5839c60 --- /dev/null +++ b/lib/db/repos/app_settings.js @@ -0,0 +1,17 @@ +import { pool } from '../pool.js'; + +// Generic owner-scoped key→jsonb settings store. Used by the speedtest schedule +// and (later) the theming panel. Keep values small + JSON-serialisable. +export async function get(key, fallback = null) { + const { rows } = await pool.query(`SELECT value FROM app_settings WHERE key = $1`, [key]); + return rows[0] ? rows[0].value : fallback; +} + +export async function set(key, value) { + const { rows } = await pool.query( + `INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2::jsonb, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now() + RETURNING value`, + [key, JSON.stringify(value)]); + return rows[0].value; +} diff --git a/lib/db/repos/speedtest.js b/lib/db/repos/speedtest.js index 1170028..709eed8 100644 --- a/lib/db/repos/speedtest.js +++ b/lib/db/repos/speedtest.js @@ -1,12 +1,51 @@ import { pool } from '../pool.js'; -export async function record({ down_mbps, up_mbps, ping_ms = null }) { + +export async function record(r = {}) { const { rows } = await pool.query( - `INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`, - [down_mbps, up_mbps, ping_ms]); + `INSERT INTO speedtest_results + (down_mbps, up_mbps, ping_ms, jitter_ms, packet_loss, server_name, server_id, + isp, result_url, down_bytes, up_bytes, ok, error) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, + [r.down_mbps ?? null, r.up_mbps ?? null, r.ping_ms ?? null, r.jitter_ms ?? null, + r.packet_loss ?? null, r.server_name ?? null, r.server_id ?? null, r.isp ?? null, + r.result_url ?? null, r.down_bytes ?? null, r.up_bytes ?? null, r.ok ?? true, r.error ?? null]); return rows[0]; } + export async function history(limit = 30) { const { rows } = await pool.query( `SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]); return rows; } + +// Rows within the last N hours (ascending for charting), capped. +export async function range(hours = 168, limit = 1000) { + const { rows } = await pool.query( + `SELECT * FROM ( + SELECT * FROM speedtest_results + WHERE ran_at >= now() - ($1 || ' hours')::interval + ORDER BY ran_at DESC LIMIT $2 + ) t ORDER BY ran_at ASC`, [hours, limit]); + return rows; +} + +export async function latest() { + const { rows } = await pool.query( + `SELECT * FROM speedtest_results WHERE ok ORDER BY ran_at DESC LIMIT 1`); + return rows[0] || null; +} + +export async function stats(hours = 24) { + const { rows } = await pool.query( + `SELECT count(*) FILTER (WHERE ok) AS n, + count(*) FILTER (WHERE NOT ok) AS failures, + avg(down_mbps) FILTER (WHERE ok) AS avg_down, + min(down_mbps) FILTER (WHERE ok) AS min_down, + max(down_mbps) FILTER (WHERE ok) AS max_down, + avg(up_mbps) FILTER (WHERE ok) AS avg_up, + avg(ping_ms) FILTER (WHERE ok) AS avg_ping, + max(ping_ms) FILTER (WHERE ok) AS max_ping + FROM speedtest_results + WHERE ran_at >= now() - ($1 || ' hours')::interval`, [hours]); + return rows[0]; +} diff --git a/lib/jobs/workers/speedtest.js b/lib/jobs/workers/speedtest.js index c81cfb7..f025892 100644 --- a/lib/jobs/workers/speedtest.js +++ b/lib/jobs/workers/speedtest.js @@ -6,18 +6,42 @@ const pexec = promisify(execFile); export const NAME = 'speedtest'; -// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags -// here if the box has the Ookla `speedtest -f json` CLI instead. -async function defaultRunner() { - const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 }); +// Ookla CLI gives the full metric set (jitter, packet loss, server, ISP, +// shareable result URL). Override the binary via SPEEDTEST_BIN if needed. +const OOKLA_BIN = process.env.SPEEDTEST_BIN || 'ookla-speedtest'; + +async function ooklaRunner() { + const { stdout } = await pexec(OOKLA_BIN, + ['-f', 'json', '--accept-license', '--accept-gdpr'], { timeout: 120000 }); const j = JSON.parse(stdout); - return { down_mbps: j.download / 1e6, up_mbps: j.upload / 1e6, ping_ms: j.ping }; + const mbps = bw => (Number(bw) || 0) * 8 / 1e6; // Ookla bandwidth is bytes/s + return { + down_mbps: mbps(j.download?.bandwidth), + up_mbps: mbps(j.upload?.bandwidth), + ping_ms: j.ping?.latency ?? null, + jitter_ms: j.ping?.jitter ?? null, + packet_loss: j.packetLoss ?? null, + server_name: j.server ? [j.server.name, j.server.location].filter(Boolean).join(' · ') : null, + server_id: j.server?.id != null ? String(j.server.id) : null, + isp: j.isp ?? null, + result_url: j.result?.url ?? null, + down_bytes: j.download?.bytes ?? null, + up_bytes: j.upload?.bytes ?? null, + ok: true + }; } -let runner = defaultRunner; +let runner = ooklaRunner; export function _setRunner(fn) { runner = fn; } export async function handler(_job) { - const r = await runner(); - await repo.record(r); - log.info(r, 'speedtest recorded'); + try { + const r = await runner(); + const saved = await repo.record(r); + log.info({ down: r.down_mbps, up: r.up_mbps, ping: r.ping_ms }, 'speedtest recorded'); + return saved; + } catch (e) { + await repo.record({ ok: false, error: String(e?.message || e).slice(0, 300) }); + log.error({ err: e }, 'speedtest failed'); + throw e; + } } diff --git a/package-lock.json b/package-lock.json index 73b0357..2ac2a00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "void-server", - "version": "2.8.0", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "void-server", - "version": "2.8.0", + "version": "2.9.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", diff --git a/package.json b/package.json index 2dde837..f1f24dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.8.0", + "version": "2.9.0", "type": "module", "private": true, "scripts": { diff --git a/public/app.js b/public/app.js index 1c3c788..4747277 100644 --- a/public/app.js +++ b/public/app.js @@ -31,7 +31,8 @@ const VIEWS = { links: () => import('./views/links.js'), mirror: () => import('./views/mirror.js'), settings: () => import('./views/settings.js'), - jobs: () => import('./views/jobs.js') + jobs: () => import('./views/jobs.js'), + speedtest: () => import('./views/speedtest.js') }; async function renderView(ctx) { diff --git a/public/components/sidebar.js b/public/components/sidebar.js index 7caa2f9..c6a7f4b 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -122,6 +122,7 @@ export function renderSidebar(root) { el('div', { class: 'sb-section' }, el('div', { class: 'sb-title' }, 'Navigate'), navItem('Sacred Valley', '/sacred-valley'), + navItem('Speedtest', '/speedtest'), navItem('Terminal', '/terminal'), navItem('Search', '/search'), inboxItem, diff --git a/public/router.js b/public/router.js index 61f7c14..d1736a5 100644 --- a/public/router.js +++ b/public/router.js @@ -33,6 +33,7 @@ const ROUTES = [ { name: 'mirror', re: /^\/mirror$/, keys: [] }, { name: 'settings', re: /^\/settings$/, keys: [] }, { name: 'jobs', re: /^\/jobs$/, keys: [] }, + { name: 'speedtest', re: /^\/speedtest$/, keys: [] }, { name: 'home', re: /^\/?$/, keys: [] } ]; diff --git a/public/style.css b/public/style.css index cec8b12..3fcb1de 100644 --- a/public/style.css +++ b/public/style.css @@ -663,6 +663,41 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .sv-tray-chip { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 14px; padding: 4px 10px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; } .sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); } +.sv-link { color: var(--muted); text-decoration: none; font-family: var(--font-ui); font-size: 11px; } +.sv-link:hover { color: var(--accent); } + +/* ---- Speedtest page ---- */ +.st-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } +.st-actions { display: flex; align-items: center; gap: 10px; } +.st-ranges { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; } +.st-range { background: transparent; border: 0; color: var(--muted); padding: 5px 12px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; } +.st-range.on { background: var(--accent-soft); color: var(--accent); } +.st-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 8px; } +.st-kpi { border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--panel); } +.st-kpi.down { border-left-color: var(--accent); } +.st-kpi.up { border-left-color: var(--ok); } +.st-kpi.bad { border-left-color: var(--bad); } +.st-kpi-l { display: block; font-family: var(--font-ui); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; } +.st-kpi-v { display: block; font-family: var(--font-mono); font-size: 26px; color: var(--text); line-height: 1.1; } +.st-kpi-s { display: block; font-size: 11px; color: var(--muted); } +.st-meta { font-size: 12px; margin: 4px 0 10px; } +.st-link { color: var(--accent); text-decoration: none; } +.st-warn { color: var(--warn); } +.st-fail td { color: var(--bad); opacity: .8; } +.st-stats { display: flex; flex-wrap: wrap; gap: 16px; font-family: var(--font-mono); font-size: 12px; color: var(--muted); margin-bottom: 18px; } +.st-h2 { font-family: var(--font-display); font-size: 14px; letter-spacing: .12em; text-transform: uppercase; color: var(--text); margin: 18px 0 6px; } +.st-chart { width: 100%; height: 180px; display: block; } +.st-legend { display: flex; gap: 16px; margin-bottom: 4px; } +.st-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); } +.st-leg i { width: 14px; height: 3px; border-radius: 2px; display: inline-block; } +.st-card { margin: 16px 0; } +.st-form { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; } +.st-lbl { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } +.st-table-wrap { overflow-x: auto; } +.st-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 12px; } +.st-table th { text-align: left; color: var(--muted); font-weight: 400; font-family: var(--font-ui); border-bottom: 1px solid var(--border); padding: 6px 10px; position: sticky; top: 0; background: var(--bg); } +.st-table td { padding: 5px 10px; border-bottom: 1px solid #ffffff08; } +.st-table td.num { text-align: right; } .hidden { display: none !important; } diff --git a/public/views/cards/speedtest.js b/public/views/cards/speedtest.js index 3ca8ef1..e7fcba8 100644 --- a/public/views/cards/speedtest.js +++ b/public/views/cards/speedtest.js @@ -1,4 +1,4 @@ -// public/views/cards/speedtest.js +// public/views/cards/speedtest.js — at-a-glance summary; full history at #/speedtest import { el, mount } from '../../dom.js'; import { api } from '../../api.js'; @@ -6,17 +6,21 @@ let body; async function load() { if (!body) return; try { - const hist = await api.get('/api/speedtest/history'); + const hist = (await api.get('/api/speedtest/history?limit=30')).filter(h => h.ok !== false); const latest = hist[0]; const max = Math.max(1, ...hist.map(h => Number(h.down_mbps))); - const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '40px', marginTop: '8px' } }, + const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '38px', marginTop: '8px' } }, hist.slice(0, 30).reverse().map(h => el('div', { style: { flex: '1', background: 'var(--accent-dim)', height: (Number(h.down_mbps) / max * 100) + '%' } }))); mount(body, el('div', { class: 'sv-row', style: { fontSize: '20px' } }, - el('span', { style: { fontFamily: 'var(--font-mono)' } }, latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'), + el('span', { style: { fontFamily: 'var(--font-mono)' } }, + latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'), el('button', { class: 'sv-run', onclick: runNow }, 'Run')), + latest ? el('div', { class: 'sv-row', style: { fontSize: '11px' } }, + el('span', { class: 'k' }, `ping ${latest.ping_ms == null ? '—' : Number(latest.ping_ms).toFixed(0)} ms · jitter ${latest.jitter_ms == null ? '—' : Number(latest.jitter_ms).toFixed(1)}`), + el('a', { href: '#/speedtest', class: 'sv-link' }, 'history ↗')) : null, bars); } catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); } } diff --git a/public/views/speedtest.js b/public/views/speedtest.js new file mode 100644 index 0000000..65541ad --- /dev/null +++ b/public/views/speedtest.js @@ -0,0 +1,185 @@ +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); +}