Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792431f65f | ||
|
|
359ae21d59 |
@@ -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);
|
||||
|
||||
@@ -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
21
lib/api/routes/theme.js
Normal 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));
|
||||
}));
|
||||
@@ -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
|
||||
|
||||
22
lib/db/migrations/028_speedtest_metrics.sql
Normal file
22
lib/db/migrations/028_speedtest_metrics.sql
Normal 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()
|
||||
);
|
||||
17
lib/db/repos/app_settings.js
Normal file
17
lib/db/repos/app_settings.js
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.8.0",
|
||||
"version": "2.10.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] }
|
||||
];
|
||||
|
||||
|
||||
@@ -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
77
public/theme.js
Normal 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;
|
||||
}
|
||||
@@ -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')); }
|
||||
}
|
||||
|
||||
@@ -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
185
public/views/speedtest.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user