diff --git a/lib/api/index.js b/lib/api/index.js index 1d97827..882a221 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -26,6 +26,7 @@ import { spacesScopedRouter as companionRouter } from './routes/companion.js'; import { router as dashboardRouter } from './routes/dashboard.js'; import { router as weatherRouter } from './routes/weather.js'; import { router as hostRouter } from './routes/host.js'; +import { router as speedtestRouter } from './routes/speedtest.js'; export function mountApi(app) { const api = Router(); @@ -59,6 +60,7 @@ export function mountApi(app) { api.use('/dashboard', dashboardRouter); api.use('/weather', weatherRouter); api.use('/host', hostRouter); + api.use('/speedtest', speedtestRouter); api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter); api.use((_req, _res, next) => next(new NotFoundError('route not found'))); diff --git a/lib/api/routes/speedtest.js b/lib/api/routes/speedtest.js new file mode 100644 index 0000000..f79f098 --- /dev/null +++ b/lib/api/routes/speedtest.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { asyncWrap } from '../errors.js'; +import { requireOwner } from '../cap.js'; +import * as repo from '../../db/repos/speedtest.js'; +import { enqueue } from '../../jobs/queue.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 }); +})); diff --git a/lib/cron/index.js b/lib/cron/index.js index ce68010..1dcc6ed 100644 --- a/lib/cron/index.js +++ b/lib/cron/index.js @@ -1,6 +1,7 @@ import cron from 'node-cron'; import { runSync } from './sync_source_docs.js'; import { log } from '../log.js'; +import { enqueue } from '../jobs/queue.js'; export function startCron() { // Daily at 03:00 local time @@ -12,5 +13,12 @@ export function startCron() { log.error({ err: e }, 'cron sync.source_doc failed'); } }); + + // Hourly speedtest + cron.schedule('0 * * * *', async () => { + try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); } + catch (e) { log.error({ err: e }, 'cron speedtest failed'); } + }); + log.info('cron started'); } diff --git a/lib/jobs/index.js b/lib/jobs/index.js index 8bc66b9..8418c1f 100644 --- a/lib/jobs/index.js +++ b/lib/jobs/index.js @@ -4,8 +4,9 @@ import * as url from './workers/url.js'; import * as blob from './workers/blob.js'; import * as embed from './workers/embed.js'; import * as karakeep from './workers/karakeep.js'; +import * as speedtest from './workers/speedtest.js'; -const WORKERS = [echo, url, blob, embed, karakeep]; +const WORKERS = [echo, url, blob, embed, karakeep, speedtest]; export async function registerWorkers() { for (const w of WORKERS) { diff --git a/lib/jobs/workers/speedtest.js b/lib/jobs/workers/speedtest.js new file mode 100644 index 0000000..c81cfb7 --- /dev/null +++ b/lib/jobs/workers/speedtest.js @@ -0,0 +1,23 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as repo from '../../db/repos/speedtest.js'; +import { log } from '../../log.js'; +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 }); + const j = JSON.parse(stdout); + return { down_mbps: j.download / 1e6, up_mbps: j.upload / 1e6, ping_ms: j.ping }; +} +let runner = defaultRunner; +export function _setRunner(fn) { runner = fn; } + +export async function handler(_job) { + const r = await runner(); + await repo.record(r); + log.info(r, 'speedtest recorded'); +} diff --git a/tests/api/speedtest.test.js b/tests/api/speedtest.test.js new file mode 100644 index 0000000..7f372fc --- /dev/null +++ b/tests/api/speedtest.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import * as repo from '../../lib/db/repos/speedtest.js'; + +let app, ownerHeaders; +beforeAll(async () => { ({ app, ownerHeaders } = await setup()); await repo.record({ down_mbps: 50, up_mbps: 10, ping_ms: 12 }); }); +describe('speedtest api', () => { + it('401 without auth', async () => expect((await request(app).get('/api/speedtest/history')).status).toBe(401)); + it('history returns rows', async () => { + const res = await request(app).get('/api/speedtest/history').set(ownerHeaders); + expect(res.status).toBe(200); + expect(res.body.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/jobs/speedtest_worker.test.js b/tests/jobs/speedtest_worker.test.js new file mode 100644 index 0000000..c431f14 --- /dev/null +++ b/tests/jobs/speedtest_worker.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as worker from '../../lib/jobs/workers/speedtest.js'; +import * as repo from '../../lib/db/repos/speedtest.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); +describe('speedtest worker', () => { + it('runs the CLI runner and records the result', async () => { + worker._setRunner(vi.fn().mockResolvedValue({ down_mbps: 95.5, up_mbps: 18.3, ping_ms: 9 })); + await worker.handler({ id: 'j1', data: {} }); + const hist = await repo.history(1); + expect(Number(hist[0].down_mbps)).toBe(95.5); + }); +});