2 Commits

Author SHA1 Message Date
root
792431f65f feat(theming): in-UI theme editor (2.10.0)
Recolour the whole UI from Settings — 12 palette colour pickers with
live preview, presets (Ember/Frost/Verdant/Amethyst), and reset to the
default Blackflame. Overrides persist in app_settings (key 'theme') via
a hex-validated /api/theme route and apply to :root on boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:01:48 +10:00
root
359ae21d59 feat(speedtest): full speedtest-tracker-style automation (2.9.0)
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>
2026-06-09 22:55:04 +10:00
18 changed files with 565 additions and 29 deletions

View File

@@ -37,6 +37,7 @@ import { router as clusterRouter } from './routes/cluster.js';
import { router as storageRouter } from './routes/storage.js';
import { router as backupsRouter } from './routes/backups.js';
import { router as kuttRouter } from './routes/kutt.js';
import { router as themeRouter } from './routes/theme.js';
export function mountApi(app) {
const api = Router();
@@ -71,6 +72,7 @@ export function mountApi(app) {
api.use('/tags', tagsRouter);
api.use('/links', linksRouter);
api.use('/kutt', kuttRouter);
api.use('/theme', themeRouter);
api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter);
api.use('/search', searchRouter);

View File

@@ -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', {}) })));

21
lib/api/routes/theme.js Normal file
View File

@@ -0,0 +1,21 @@
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 settings from '../../db/repos/app_settings.js';
export const router = Router();
// Theme = a small map of palette-var overrides, e.g. { accent: '#ff4f2e' }.
// Keys are short slugs (mapped to --<key> on the client); values must be hex,
// so a saved theme can never inject arbitrary CSS.
const themeSchema = z.record(
z.string().regex(/^[a-z0-9-]{1,24}$/),
z.string().regex(/^#[0-9a-fA-F]{3,8}$/)
);
router.get('/', asyncWrap(async (_req, res) => res.json(await settings.get('theme', {}))));
router.put('/', requireOwner, validate({ body: themeSchema }), asyncWrap(async (req, res) => {
res.json(await settings.set('theme', req.body));
}));

View File

@@ -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

View File

@@ -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()
);

View File

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

View File

@@ -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];
}

View File

@@ -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) {
try {
const r = await runner();
await repo.record(r);
log.info(r, 'speedtest recorded');
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;
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "void-server",
"version": "2.8.0",
"version": "2.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "void-server",
"version": "2.8.0",
"version": "2.10.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.8.0",
"version": "2.10.0",
"type": "module",
"private": true,
"scripts": {

View File

@@ -11,6 +11,7 @@ import { emit, state } from './state.js';
import { el, mount } from './dom.js';
import { attachDropzone } from './components/dropzone.js';
import { initChrome } from './components/chrome.js';
import { loadTheme } from './theme.js';
const VIEWS = {
home: () => import('./views/home.js'),
@@ -31,7 +32,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) {
@@ -79,6 +81,7 @@ async function init() {
try { await api.get('/api/spaces'); }
catch { /* api wrapper opens the modal on 401 */ }
}
await loadTheme(); // apply saved palette overrides before rendering chrome
renderTopbar(document.getElementById('topbar'));
renderSidebar(document.getElementById('sidebar'));
renderRightrail(document.getElementById('rightrail'));

View File

@@ -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,

View File

@@ -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: [] }
];

View File

@@ -663,6 +663,47 @@ 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; }
/* ---- Theming panel ---- */
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 18px; margin-bottom: 14px; }
.theme-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: var(--muted); }
.theme-row input[type=color] { width: 40px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 4px; background: none; cursor: pointer; }
.theme-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.hidden { display: none !important; }

77
public/theme.js Normal file
View File

@@ -0,0 +1,77 @@
// Theming: a small map of palette-var overrides persisted in app_settings and
// applied to :root on boot. The whole UI is CSS-custom-property driven, so
// setting these vars recolours everything live. (Canvas-drawn colours — the
// blackflame card — and a few inline rgba() literals don't follow the theme.)
import { api } from './api.js';
export const THEME_VARS = [
{ key: 'accent', css: '--accent', label: 'Accent (flame)' },
{ key: 'accent-dim', css: '--accent-dim', label: 'Accent · dim' },
{ key: 'accent-soft', css: '--accent-soft', label: 'Accent · soft' },
{ key: 'bg', css: '--bg', label: 'Background' },
{ key: 'panel', css: '--panel', label: 'Panel' },
{ key: 'panel-2', css: '--panel-2', label: 'Panel · raised' },
{ key: 'border', css: '--border', label: 'Border' },
{ key: 'text', css: '--text', label: 'Text' },
{ key: 'muted', css: '--muted', label: 'Muted text' },
{ key: 'ok', css: '--ok', label: 'OK / good' },
{ key: 'warn', css: '--warn', label: 'Warning' },
{ key: 'bad', css: '--bad', label: 'Bad / error' }
];
const BY_KEY = Object.fromEntries(THEME_VARS.map(v => [v.key, v]));
// Named alternates. Blackflame = {} (clear overrides → CSS defaults).
export const PRESETS = {
Blackflame: {},
Ember: { accent: '#ff7a1a', 'accent-dim': '#8a3a10', 'accent-soft': '#3a1a0a', bg: '#0c0907', panel: '#171008', 'panel-2': '#20160c' },
Frost: { accent: '#4aa3ff', 'accent-dim': '#1e5a8a', 'accent-soft': '#0e2230', bg: '#070a0e', panel: '#0f141c', 'panel-2': '#161d28', ok: '#5fb0c4' },
Verdant: { accent: '#5fc46a', 'accent-dim': '#2a6a30', 'accent-soft': '#10240f', bg: '#070b08', panel: '#0f160f', 'panel-2': '#161f16' },
Amethyst: { accent: '#a86adf', 'accent-dim': '#5a2e8a', 'accent-soft': '#1e1030', bg: '#0a0810', panel: '#140f1c', 'panel-2': '#1c1528' }
};
export function applyTheme(vars = {}) {
const root = document.documentElement;
for (const [k, val] of Object.entries(vars)) {
const def = BY_KEY[k];
if (def && val) root.style.setProperty(def.css, val);
}
}
export function clearTheme() {
const root = document.documentElement;
for (const v of THEME_VARS) root.style.removeProperty(v.css);
}
// Current effective value of a var (override or CSS default), normalised to #rrggbb.
export function effectiveHex(key) {
const def = BY_KEY[key];
if (!def) return '#000000';
const raw = getComputedStyle(document.documentElement).getPropertyValue(def.css).trim();
return toHex6(raw) || '#000000';
}
export function toHex6(v) {
if (!v) return '';
v = v.trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) return v.toLowerCase();
if (/^#[0-9a-fA-F]{8}$/.test(v)) return v.slice(0, 7).toLowerCase(); // drop alpha
if (/^#[0-9a-fA-F]{3}$/.test(v)) return '#' + v.slice(1).split('').map(c => c + c).join('').toLowerCase();
const m = v.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
if (m) return '#' + [m[1], m[2], m[3]].map(n => (+n).toString(16).padStart(2, '0')).join('');
return '';
}
let current = {};
export function currentTheme() { return { ...current }; }
export async function loadTheme() {
try { current = (await api.get('/api/theme')) || {}; applyTheme(current); }
catch { /* defaults */ }
return current;
}
export async function saveTheme(vars) {
current = (await api.put('/api/theme', vars)) || {};
clearTheme(); applyTheme(current);
return current;
}

View File

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

View File

@@ -2,6 +2,57 @@
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { iconSetsPanel } from './icon_sets_panel.js';
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
// Theming — colour pickers for the palette, live-preview on input, presets +
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
function themingBody() {
const cur = currentTheme(); // saved overrides (subset of vars)
const grid = el('div', { class: 'theme-grid' });
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
function rebuild() {
mount(grid, THEME_VARS.map(v => {
const inp = el('input', { type: 'color', value: cur[v.key] ? toHex6(cur[v.key]) : effectiveHex(v.key) });
inp.addEventListener('input', () => {
cur[v.key] = inp.value;
document.documentElement.style.setProperty(v.css, inp.value); // live preview
});
return el('label', { class: 'theme-row' }, el('span', {}, v.label), inp);
}));
}
rebuild();
const preset = el('select', { class: 'pm-input', style: { maxWidth: '160px' } },
el('option', { value: '' }, 'Apply preset…'),
...Object.keys(PRESETS).map(n => el('option', { value: n }, n)));
preset.addEventListener('change', () => {
if (!preset.value) return;
clearTheme();
for (const k of Object.keys(cur)) delete cur[k];
Object.assign(cur, PRESETS[preset.value]);
applyTheme(cur);
rebuild();
preset.value = '';
});
const save = el('button', { class: 'primary' }, 'Save theme');
save.onclick = async () => {
try { await saveTheme(cur); out.textContent = 'Saved — applies everywhere.'; }
catch { out.textContent = 'Save failed'; }
};
const reset = el('button', { class: 'ghost' }, 'Reset to Blackflame');
reset.onclick = async () => {
for (const k of Object.keys(cur)) delete cur[k];
clearTheme();
try { await saveTheme({}); rebuild(); out.textContent = 'Reset to default.'; }
catch { out.textContent = 'Reset failed'; }
};
return el('div', { class: 'settings-body' },
grid,
el('div', { class: 'theme-actions' }, preset, save, reset, out));
}
function section(title, sub, bodyEl) {
return el('div', { class: 'card settings-card' },
@@ -120,6 +171,7 @@ export async function render(main) {
mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'),
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),

185
public/views/speedtest.js Normal file
View File

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