4 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
root
600057582e feat(sacred-valley): hybrid free/snap canvas + blank & blackflame cards (2.8.0)
Replace masonry grid with an absolute-positioned 12-col canvas: drag to
move, corner to resize, per-card free/overlap toggle (Alt = no-snap).
Geometry persisted (migration 027: dashboard_layout.geom + extras).
Two new addable decorative cards: blank spacer + animated blackflame
(canvas particle flame). Old layout auto-migrates by flow-placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:33:45 +10:00
root
e8f655ed27 feat(sacred-valley): masonry card layout — content-height row spans + dense flow
Cards keep their column span but now span grid rows proportional to
measured content height (ResizeObserver re-packs async cards), with
grid-auto-flow: dense. Fixes mismatched sizes / rigid rows / vertical gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:58:24 +10:00
26 changed files with 1115 additions and 152 deletions

View File

@@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Blackflame card — mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
--text:#e8e6ed; --muted:#888094;
--accent:#ff4f2e; --accent-dim:#7a2716; --accent-soft:#3a1610;
--font-display:'Cinzel',serif; --font-mono:'JetBrains Mono',monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:#06060a;min-height:100vh;display:grid;place-items:center;
font-family:var(--font-mono);color:var(--text);gap:18px;padding:40px}
/* a Sacred-Valley-style card shell */
.sv-card{
width:440px;height:440px;position:relative;border-radius:14px;
background:radial-gradient(120% 120% at 50% 18%, #14131b 0%, #0b0a10 60%, #08070c 100%);
border:1px solid var(--border);overflow:hidden;
box-shadow:0 0 0 1px #00000060, 0 24px 60px -20px #000,
inset 0 0 70px -30px var(--accent-soft);
}
.sv-card::after{ /* faint inner ring */
content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
box-shadow:inset 0 0 0 1px #ff4f2e12;
}
canvas{position:absolute;inset:0;width:100%;height:100%}
.label{
position:absolute;left:0;right:0;bottom:22px;text-align:center;z-index:3;
font-family:var(--font-display);letter-spacing:.42em;text-transform:uppercase;
font-size:15px;color:#f4ece9;text-indent:.42em;
text-shadow:0 0 18px #ff4f2e88, 0 0 4px #000;
}
.sub{
position:absolute;left:0;right:0;bottom:8px;text-align:center;z-index:3;
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.32em;
text-transform:uppercase;color:#6a6475;
}
.crest{ /* the still, dark heart of the flame */
position:absolute;top:50%;left:50%;width:84px;height:84px;z-index:2;
transform:translate(-50%,-58%);border-radius:50%;
background:radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
box-shadow:0 0 26px 10px #000, 0 0 60px 18px #ff4f2e22;
}
.hint{font-family:var(--font-mono);font-size:11px;color:#6a6475;letter-spacing:.04em}
</style>
</head>
<body>
<div class="sv-card" id="card">
<canvas id="fx"></canvas>
<div class="crest"></div>
<div class="label">The&nbsp;Void</div>
<div class="sub">Sacred Valley · core</div>
</div>
<div class="hint">Blackflame · canvas particle flame (dark heart, crimson licks, rising embers)</div>
<script>
const cv = document.getElementById('fx');
const ctx = cv.getContext('2d');
let W,H,DPR;
function resize(){
DPR = Math.min(2, window.devicePixelRatio||1);
const r = cv.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = W*DPR; cv.height = H*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
}
resize(); addEventListener('resize', resize);
// ---- rising flame: stacked soft blobs along oscillating "tongues" + embers ----
const TAU = Math.PI*2;
const tongues = Array.from({length:5}, (_,i)=>({
phase: Math.random()*TAU, speed: .6+Math.random()*.5,
x: 0.5 + (i-2)*0.085, sway: 0.03+Math.random()*0.04, w: 0.34-Math.abs(i-2)*0.05
}));
const embers = Array.from({length:46}, ()=>spawn());
function spawn(){ return {
x: 0.5 + (Math.random()-0.5)*0.5, y: 1+Math.random()*0.2,
vy: 0.0016+Math.random()*0.0030, vx:(Math.random()-0.5)*0.0012,
r: 0.6+Math.random()*1.8, life: 0, max: 120+Math.random()*120,
hot: Math.random()<0.5 };
}
function blob(x,y,r,c0,c1){
const g = ctx.createRadialGradient(x,y,0,x,y,r);
g.addColorStop(0,c0); g.addColorStop(1,c1);
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,TAU); ctx.fill();
}
let t=0;
function frame(){
t += 0.016;
// base wash
ctx.globalCompositeOperation='source-over';
ctx.clearRect(0,0,W,H);
// flame body — additive so overlaps glow
ctx.globalCompositeOperation='lighter';
const baseY = H*0.92;
for(const tg of tongues){
const cx = W*(tg.x + Math.sin(t*tg.speed+tg.phase)*tg.sway);
const height = H*(0.62 + 0.08*Math.sin(t*1.7+tg.phase));
const steps = 26;
for(let s=0;s<steps;s++){
const f = s/steps; // 0 base → 1 tip
const y = baseY - f*height;
const flick = Math.sin(t*3.2 + tg.phase + f*7)*W*0.012*(0.4+f);
const x = cx + flick + Math.sin(t*tg.speed*1.3+f*4)*W*tg.sway*0.6;
const r = W*tg.w*(1-f*0.78)*(0.9+0.1*Math.sin(t*5+f*9));
// colour ramps dark-red → crimson → fades at the tip (black-flame: dark heart)
const a = (1-f)*0.5;
if(f<0.18){ blob(x,y,r*1.1, `rgba(60,12,6,${a*0.7})`, 'rgba(60,12,6,0)'); }
else if(f<0.62){ blob(x,y,r, `rgba(255,79,46,${a*0.5})`, 'rgba(122,39,22,0)'); }
else { blob(x,y,r*0.8, `rgba(255,150,90,${a*0.5})`, 'rgba(255,79,46,0)'); }
}
}
// embers
for(const e of embers){
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t*2+e.y*8)*0.0004;
if(e.y<0.18 || e.life>e.max){ Object.assign(e, spawn()); }
const px=e.x*W, py=e.y*H, fade=1-e.life/e.max;
const col = e.hot? `rgba(255,170,110,${fade*0.9})` : `rgba(255,79,46,${fade*0.8})`;
ctx.shadowBlur=8; ctx.shadowColor='#ff4f2e';
blob(px,py,e.r*1.6, col, 'rgba(255,79,46,0)');
}
ctx.shadowBlur=0;
// carve the dark heart back in (the "black" of black-flame)
ctx.globalCompositeOperation='source-over';
const cx=W*0.5, cy=H*0.46, cr=W*0.20;
const core = ctx.createRadialGradient(cx,cy,0,cx,cy,cr);
core.addColorStop(0,'rgba(4,4,8,0.96)');
core.addColorStop(0.55,'rgba(6,5,10,0.7)');
core.addColorStop(1,'rgba(8,7,12,0)');
ctx.fillStyle=core; ctx.beginPath(); ctx.arc(cx,cy,cr,0,TAU); ctx.fill();
// base glow pool
ctx.globalCompositeOperation='lighter';
blob(W*0.5, H*0.95, W*0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
requestAnimationFrame(frame);
}
frame();
</script>
</body>
</html>

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

@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
export const router = Router();
router.use(requireOwner);
const geomCell = z.object({
x: z.number(), y: z.number(),
w: z.number().positive(), h: z.number().positive(),
free: z.boolean().optional()
});
const layoutSchema = z.object({
card_order: z.array(z.string()).default([]),
hidden: z.array(z.string()).default([]),
// Per-card width: an integer column span 112 (legacy 's'|'m'|'l' still accepted).
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}),
// Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units.
geom: z.record(geomCell).default({}),
// User-added decorative card instances that must survive reloads.
extras: z.array(z.object({
id: z.string().min(1).max(64),
type: z.enum(['blank', 'blackflame'])
})).default([])
});
router.get('/layout', asyncWrap(async (_req, res) => {

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,7 @@
-- 027_dashboard_geom.sql
-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card
-- instances (blank spacers, blackflame). geom is keyed by card id →
-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the
-- user-added decorative cards so they survive reloads.
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb;

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,24 +1,28 @@
import { pool } from '../pool.js';
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
export async function get() {
const { rows } = await pool.query(
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
`SELECT card_order, hidden, sizes, geom, extras
FROM dashboard_layout WHERE owner_key = 'owner'`
);
return rows[0] || { ...DEFAULTS };
}
export async function put({ card_order = [], hidden = [], sizes = {} }) {
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
await pool.query(
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
ON CONFLICT (owner_key) DO UPDATE
SET card_order = EXCLUDED.card_order,
hidden = EXCLUDED.hidden,
sizes = EXCLUDED.sizes,
geom = EXCLUDED.geom,
extras = EXCLUDED.extras,
updated_at = now()`,
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
JSON.stringify(geom), JSON.stringify(extras)]
);
return get();
}

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "void-server",
"version": "2.6.6",
"version": "2.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "void-server",
"version": "2.6.6",
"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.6.6",
"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

@@ -1,14 +1,13 @@
import { el } from '../dom.js';
// Builds the refined-B chrome shell and returns { root, body }. The card module
// fills `body` in its mount(); start()/stop() own its refresh timer.
// fills `body` in its mount(); start()/stop() own its refresh timer. Position +
// size are set by the Sacred Valley canvas (absolute geometry), not here.
// Decorative cards (blank / blackflame) carry no title bar.
export function svCard(def) {
const body = el('div', { class: 'sv-card-body' });
const root = el('div', {
class: 'sv-card', dataset: { cardId: def.id },
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
},
el('div', { class: 'sv-card-title' }, def.title),
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
body
);
return { root, body };

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

@@ -382,14 +382,9 @@ ul.plain li:last-child { border-bottom: none; }
/* reserved for a future agent-output phase — unused now:
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
}
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (112) */
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; }
.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted);
border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; }
.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; }
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
#sv-cards { position: relative; width: 100%; min-height: 200px; }
.sv-card {
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
@@ -407,8 +402,11 @@ ul.plain li:last-child { border-bottom: none; }
border-color: #4a2c28; transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
}
.sv-card.dragging { opacity: .5; }
.sv-card.drag-over { border-color: var(--accent); }
.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; }
#sv-cards.editing .sv-card { transition: none; }
#sv-cards.editing .sv-card:hover { transform: none; }
.sv-card-body { height: 100%; overflow: auto; }
.sv-card-title {
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
@@ -626,18 +624,38 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
#sv-cards.editing .sv-card-edit { display: flex; }
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; }
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; }
.sv-grip:active { cursor: grabbing; }
.sv-ed-sizes { display: flex; gap: 2px; }
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
.sv-ed-size { color: var(--muted); }
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
.sv-ed-hide { color: var(--bad); font-size: 12px; }
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; }
.sv-ed-free { color: var(--muted); }
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.sv-ed-hide { color: var(--bad); }
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
/* resize handle — bottom-right corner, edit mode only */
.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4;
cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0;
background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%,
transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; }
.sv-resize:hover { opacity: 1; }
#sv-cards.editing .sv-resize { display: block; }
.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; }
/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */
.sv-card-decor { padding: 0; overflow: hidden; }
.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; }
.sv-card-decor:hover { transform: none; }
.sv-blank { width: 100%; height: 100%;
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); }
.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%);
border-radius: 50%; pointer-events: none;
background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); }
.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none;
font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase;
font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; }
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
border: 1px dashed var(--border); border-radius: 8px; }
@@ -645,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

@@ -0,0 +1,114 @@
// Decorative blackflame card — the animated centrepiece (dark heart, crimson
// licks, rising embers). Canvas particle flame ported from the approved mock.
// Instanceable via the factory; stop() tears down the rAF loop + observer.
import { el } from '../../dom.js';
const TAU = Math.PI * 2;
function startFlame(canvas) {
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
const r = canvas.getBoundingClientRect();
W = r.width; H = r.height;
canvas.width = Math.max(1, Math.round(W * DPR));
canvas.height = Math.max(1, Math.round(H * DPR));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
const tongues = Array.from({ length: 5 }, (_, i) => ({
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
}));
const spawn = () => ({
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
});
const embers = Array.from({ length: 46 }, spawn);
function blob(x, y, r, c0, c1) {
if (r <= 0) return;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, c0); g.addColorStop(1, c1);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
}
function frame() {
t += 0.016;
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
const baseY = H * 0.92;
for (const tg of tongues) {
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
const steps = 26;
for (let s = 0; s < steps; s++) {
const f = s / steps;
const y = baseY - f * height;
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
const a = (1 - f) * 0.5;
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
}
}
for (const e of embers) {
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
}
ctx.shadowBlur = 0;
// carve the dark heart back in (the "black" of black-flame)
ctx.globalCompositeOperation = 'source-over';
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
core.addColorStop(0, 'rgba(4,4,8,0.96)');
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
core.addColorStop(1, 'rgba(8,7,12,0)');
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
ctx.globalCompositeOperation = 'lighter';
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
raf = requestAnimationFrame(frame);
}
resize();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
ro?.observe(canvas);
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
}
export function blackflameCard(id) {
let teardown = null;
return {
id,
type: 'blackflame',
title: '',
decorative: true,
size: 'l',
mount(body) {
const canvas = el('canvas', { class: 'sv-flame-canvas' });
const crest = el('div', { class: 'sv-flame-crest' });
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
body.append(canvas, crest, label);
// defer one frame so the canvas has a measured size
requestAnimationFrame(() => { teardown = startFlame(canvas); });
},
start() {},
stop() { teardown && teardown(); teardown = null; }
};
}

View File

@@ -0,0 +1,16 @@
// A decorative blank spacer card — deliberate empty space / grouping on the
// Sacred Valley canvas. Instanceable: each gets a unique id via the factory.
import { el } from '../../dom.js';
export function blankCard(id) {
return {
id,
type: 'blank',
title: '',
decorative: true,
size: 'm',
mount(body) { body.appendChild(el('div', { class: 'sv-blank' })); },
start() {},
stop() {}
};
}

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

@@ -3,8 +3,6 @@ import { api } from '../api.js';
import { renderHealthBand, stopHealthBand } from './health_band.js';
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
import { svCard } from '../components/sv_card.js';
import { moveId } from '../components/sv_reorder.js';
import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js';
import weather from './cards/weather.js';
import hostPerf from './cards/host_perf.js';
@@ -16,127 +14,228 @@ import aiUsage from './cards/ai_usage.js';
import cluster from './cards/cluster.js';
import storage from './cards/storage.js';
import backups from './cards/backups.js';
import { blankCard } from './cards/blank.js';
import { blackflameCard } from './cards/blackflame.js';
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
// ---- hybrid canvas geometry ----
// Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units
// (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a
// per-card `free` flag (or holding Alt while dragging) allows fractional
// placement + overlap. Everything scales with board width, so x/w stay relative.
const COLS = 12;
const ROW_H = 28; // px per grid row
const GUTTER = 12; // visual gap baked into each card's rendered size
const SIZE_W = { s: 3, m: 4, l: 6 };
const SIZE_H = { s: 6, m: 8, l: 10 };
let active = []; // mounted cards needing stop()
let renderGen = 0; // guards overlapping async renders
let editing = false;
let layout = { card_order: [], hidden: [], sizes: {} };
let mainEl;
let layout = { hidden: [], geom: {}, extras: [] };
const grid = () => document.getElementById('sv-cards');
async function saveLayout() {
try { await api.put('/api/dashboard/layout', layout); }
catch (e) { console.error('save layout', e); }
function defFor(extra) {
if (extra.type === 'blank') return blankCard(extra.id);
if (extra.type === 'blackflame') return blackflameCard(extra.id);
return null;
}
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
function spanOf(def) {
const v = layout.sizes?.[def.id];
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
if (typeof v === 'string') return STR_SPAN[v] || 6;
return STR_SPAN[def.size] || 6;
function visibleDefs() {
const hidden = new Set(layout.hidden || []);
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
const extras = (layout.extras || []).map(defFor).filter(Boolean);
return [...builtins, ...extras];
}
function curSpan(id) {
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
function defaultSize(def) {
if (def.type === 'blackflame') return { w: 6, h: 10 };
if (def.type === 'blank') return { w: 3, h: 4 };
return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 };
}
function setSpan(id, delta) {
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
layout.sizes = { ...layout.sizes, [id]: span };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) {
node.style.gridColumn = 'span ' + span;
const lbl = node.querySelector('.sv-span-val');
if (lbl) lbl.textContent = span;
function geomOf(def) {
const g = (layout.geom || {})[def.id];
if (g) return g;
const { w, h } = defaultSize(def);
return { x: 0, y: 0, w, h };
}
async function saveLayout() {
try {
await api.put('/api/dashboard/layout', {
card_order: [], sizes: {},
hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || []
});
} catch (e) { console.error('save layout', e); }
}
// Backfill geometry for any visible card lacking it (first load / migration /
// a newly-shipped built-in). New cards flow below whatever already has a spot.
function autoPlaceMissing(defs) {
const geom = { ...(layout.geom || {}) };
let baseY = 0;
for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h);
let cx = 0, cy = baseY, rowH = 0;
for (const d of defs) {
if (geom[d.id]) continue;
const { w, h } = defaultSize(d);
if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; }
geom[d.id] = { x: cx, y: cy, w, h };
cx += w; rowH = Math.max(rowH, h);
}
layout.geom = geom;
}
function cellW() { return grid().clientWidth / COLS; }
function applyGeom(node, g) {
const cw = cellW();
node.style.position = 'absolute';
node.style.left = (g.x * cw) + 'px';
node.style.top = (g.y * ROW_H) + 'px';
node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px';
node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px';
node.style.zIndex = g.free ? 5 : 1;
node.classList.toggle('free', !!g.free);
}
function fitBoard() {
let max = 0;
grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); });
grid().style.height = (max + GUTTER) + 'px';
}
function relayout() {
active.forEach(def => {
const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
if (n) applyGeom(n, geomOf(def));
});
fitBoard();
}
// ---- drag / resize (pointer-based; snap unless free or Alt held) ----
function beginDrag(ev, def, mode) {
if (!editing) return;
ev.preventDefault(); ev.stopPropagation();
const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
if (!node) return;
const g = { ...geomOf(def) };
const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h };
node.classList.add('dragging');
const cw = cellW();
function moveTo(e) {
const dxc = (e.clientX - start.px) / cw;
const dyc = (e.clientY - start.py) / ROW_H;
const freeNow = g.free || e.altKey;
if (mode === 'move') {
let nx = start.x + dxc, ny = start.y + dyc;
if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); }
g.x = Math.max(0, Math.min(COLS - g.w, nx));
g.y = Math.max(0, ny);
} else {
let nw = start.w + dxc, nh = start.h + dyc;
if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); }
g.w = Math.max(2, Math.min(COLS - g.x, nw));
g.h = Math.max(2, nh);
}
applyGeom(node, g); fitBoard();
}
function end() {
document.removeEventListener('pointermove', moveTo);
document.removeEventListener('pointerup', end);
node.classList.remove('dragging');
layout.geom = { ...layout.geom, [def.id]: g };
applyGeom(node, g);
saveLayout();
}
document.addEventListener('pointermove', moveTo);
document.addEventListener('pointerup', end);
}
function toggleFree(id) {
const g = { ...(layout.geom[id] || geomOf({ id })) };
g.free = !g.free;
layout.geom = { ...layout.geom, [id]: g };
const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (n) applyGeom(n, g);
saveLayout();
}
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const stepper = el('span', { class: 'sv-ed-span' },
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, ''),
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '');
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
const frag = document.createDocumentFragment();
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
return frag;
}
function mountOne(def) {
const span = spanOf(def);
const { root, body } = svCard({ ...def, span });
const { root, body } = svCard(def);
if (def.decorative) root.classList.add('sv-card-decor');
root.appendChild(editOverlay(def));
applyGeom(root, geomOf(def));
grid().appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
function setSize(id, s) {
layout.sizes = { ...layout.sizes, [id]: s };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) node.dataset.size = s;
saveLayout();
function placeNew(def) {
let maxBottom = 0;
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
const { w, h } = defaultSize(def);
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
}
function hideCard(id) {
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
const def = BY_ID.get(id);
if (def?.stop) def.stop();
active = active.filter(d => d.id !== id);
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
renderTray();
saveLayout();
function addBuiltin(id) {
layout.hidden = (layout.hidden || []).filter(x => x !== id);
const def = BUILTIN_BY_ID.get(id);
if (!def) return;
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
}
function showCard(id) {
layout.hidden = layout.hidden.filter(x => x !== id);
const def = BY_ID.get(id);
if (def) { mountOne(def); wireDrag(); }
renderTray();
saveLayout();
function addDecor(type) {
const id = `${type}-${Date.now().toString(36)}`;
const def = defFor({ id, type });
if (!def) return;
layout.extras = [...(layout.extras || []), { id, type }];
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
}
function onReorder(newOrder) {
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid().appendChild(frag);
layout.card_order = newOrder;
saveLayout();
}
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
function wireDrag() {
let dragId = null;
grid().querySelectorAll('.sv-card').forEach(card => {
if (card._wired) return; card._wired = true;
const g = card.querySelector('.sv-grip');
if (g) {
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
}
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault(); card.classList.remove('drag-over');
if (!dragId || dragId === card.dataset.cardId) return;
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
onReorder(moveId(ids, dragId, card.dataset.cardId));
});
});
function removeCard(def) {
const d = active.find(a => a.id === def.id);
if (d && d.stop) d.stop();
active = active.filter(a => a.id !== def.id);
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
if (def.decorative) {
layout.extras = (layout.extras || []).filter(e => e.id !== def.id);
const g = { ...layout.geom }; delete g[def.id]; layout.geom = g;
} else if (!(layout.hidden || []).includes(def.id)) {
layout.hidden = [...(layout.hidden || []), def.id];
}
renderTray(); fitBoard(); saveLayout();
}
function renderTray() {
const tray = document.getElementById('sv-tray');
if (!tray) return;
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean);
mount(tray,
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
el('span', { class: 'sv-tray-label' }, 'Add card:'),
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'),
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'),
hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null,
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)),
el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap'));
tray.style.display = editing ? 'flex' : 'none';
}
@@ -150,9 +249,8 @@ function toggleEdit() {
renderTray();
}
let mainEl;
async function resetLayout() {
layout = { card_order: [], hidden: [], sizes: {} };
layout = { hidden: [], geom: {}, extras: [] };
await saveLayout();
render(mainEl);
}
@@ -175,12 +273,21 @@ export async function render(main) {
el('div', { id: 'sv-devices' })
);
layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
layout = { hidden: [], geom: {}, extras: [] };
try {
const l = await api.get('/api/dashboard/layout');
layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] };
} catch { /* defaults */ }
if (myGen !== renderGen) return;
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
wireDrag();
const defs = visibleDefs();
autoPlaceMissing(defs);
for (const def of defs) mountOne(def);
relayout();
renderTray();
window.removeEventListener('resize', relayout);
window.addEventListener('resize', relayout);
renderHealthBand(document.getElementById('sv-health'));
renderDevicesBand(document.getElementById('sv-devices'));
}

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