Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792431f65f | ||
|
|
359ae21d59 | ||
|
|
600057582e | ||
|
|
e8f655ed27 | ||
|
|
25ac261862 | ||
|
|
15de56dbe6 | ||
|
|
442bb6ccc9 | ||
|
|
ea20c55917 | ||
|
|
4ef7fa2d75 | ||
|
|
b17cdb7f77 | ||
|
|
b967c0bfdd | ||
|
|
16e324102e |
150
docs/mockups/blackflame-card.html
Normal file
150
docs/mockups/blackflame-card.html
Normal 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 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>
|
||||||
@@ -35,7 +35,9 @@ import { router as aiUsageRouter } from './routes/ai_usage.js';
|
|||||||
import { router as infraRouter } from './routes/infra.js';
|
import { router as infraRouter } from './routes/infra.js';
|
||||||
import { router as clusterRouter } from './routes/cluster.js';
|
import { router as clusterRouter } from './routes/cluster.js';
|
||||||
import { router as storageRouter } from './routes/storage.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 kuttRouter } from './routes/kutt.js';
|
||||||
|
import { router as themeRouter } from './routes/theme.js';
|
||||||
|
|
||||||
export function mountApi(app) {
|
export function mountApi(app) {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
@@ -52,6 +54,7 @@ export function mountApi(app) {
|
|||||||
api.use('/infra', infraRouter);
|
api.use('/infra', infraRouter);
|
||||||
api.use('/cluster', clusterRouter);
|
api.use('/cluster', clusterRouter);
|
||||||
api.use('/storage', storageRouter);
|
api.use('/storage', storageRouter);
|
||||||
|
api.use('/backups', backupsRouter);
|
||||||
api.use('/little-blue', littleblueRouter);
|
api.use('/little-blue', littleblueRouter);
|
||||||
api.use('/ai-usage', aiUsageRouter);
|
api.use('/ai-usage', aiUsageRouter);
|
||||||
api.use('/projects', projectsRouter);
|
api.use('/projects', projectsRouter);
|
||||||
@@ -69,6 +72,7 @@ export function mountApi(app) {
|
|||||||
api.use('/tags', tagsRouter);
|
api.use('/tags', tagsRouter);
|
||||||
api.use('/links', linksRouter);
|
api.use('/links', linksRouter);
|
||||||
api.use('/kutt', kuttRouter);
|
api.use('/kutt', kuttRouter);
|
||||||
|
api.use('/theme', themeRouter);
|
||||||
api.use('/pending-changes', pendingChangesRouter);
|
api.use('/pending-changes', pendingChangesRouter);
|
||||||
api.use('/audit', auditRouter);
|
api.use('/audit', auditRouter);
|
||||||
api.use('/search', searchRouter);
|
api.use('/search', searchRouter);
|
||||||
|
|||||||
30
lib/api/routes/backups.js
Normal file
30
lib/api/routes/backups.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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 backups from '../../db/repos/backups.js';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
export const ingest = z.object({
|
||||||
|
ok: z.boolean().optional(),
|
||||||
|
total_bytes: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
won_free_bytes: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
guests: z.array(z.object({
|
||||||
|
vmid: z.union([z.number().int(), z.string()]),
|
||||||
|
name: z.string().max(64),
|
||||||
|
bytes: z.number().int().nonnegative()
|
||||||
|
})).max(50).nullable().optional(),
|
||||||
|
duration_sec: z.number().int().nonnegative().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/backups — the offsite-backup script reports a run (owner only).
|
||||||
|
router.post('/', requireOwner, validate({ body: ingest }), asyncWrap(async (req, res) => {
|
||||||
|
res.status(201).json(await backups.record(req.body));
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/backups — latest run + count, for the Sacred Valley "Backups" card.
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => {
|
||||||
|
res.json({ latest: await backups.latest(), count: await backups.count(), schedule: 'Sun 02:00' });
|
||||||
|
}));
|
||||||
@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
|
|||||||
export const router = Router();
|
export const router = Router();
|
||||||
router.use(requireOwner);
|
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({
|
const layoutSchema = z.object({
|
||||||
card_order: z.array(z.string()).default([]),
|
card_order: z.array(z.string()).default([]),
|
||||||
hidden: z.array(z.string()).default([]),
|
hidden: z.array(z.string()).default([]),
|
||||||
// Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted).
|
// Per-card width: an integer column span 1–12 (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) => {
|
router.get('/layout', asyncWrap(async (_req, res) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireOwner } from '../cap.js';
|
|||||||
import { validate } from '../validate.js';
|
import { validate } from '../validate.js';
|
||||||
import { grouped, iconSlug } from '../../health/registry.js';
|
import { grouped, iconSlug } from '../../health/registry.js';
|
||||||
import * as services from '../../db/repos/monitored_services.js';
|
import * as services from '../../db/repos/monitored_services.js';
|
||||||
|
import * as devices from '../../db/repos/lan_devices.js';
|
||||||
import * as statusRepo from '../../db/repos/service_status.js';
|
import * as statusRepo from '../../db/repos/service_status.js';
|
||||||
import { enqueue } from '../../jobs/queue.js';
|
import { enqueue } from '../../jobs/queue.js';
|
||||||
|
|
||||||
@@ -29,7 +30,13 @@ router.get('/services', asyncWrap(async (_req, res) => {
|
|||||||
|
|
||||||
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
|
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
|
||||||
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||||
res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) })));
|
// Cross-reference each candidate's host IP with the Network Devices band so the
|
||||||
|
// tile can show a known device name instead of a bare IP:port.
|
||||||
|
const byIp = Object.fromEntries(
|
||||||
|
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||||
|
res.json((await services.listDiscovered()).map(s => ({
|
||||||
|
...s, icon: iconSlug(s), device: byIp[s.host] || null
|
||||||
|
})));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });
|
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ router.get('/:set/:file', asyncWrap(async (req, res) => {
|
|||||||
try { buf = await sets.readIcon(req.params.set, req.params.file); }
|
try { buf = await sets.readIcon(req.params.set, req.params.file); }
|
||||||
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
|
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
|
||||||
const ct = req.params.file.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
const ct = req.params.file.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||||
res.set('Content-Type', ct).set('Cache-Control', 'public, max-age=86400').send(buf);
|
// no-cache => browsers/CF revalidate (304 via Express's ETag when unchanged), so
|
||||||
|
// icon updates propagate immediately instead of being stuck for a day. Icons are
|
||||||
|
// tiny, so the revalidation cost is negligible.
|
||||||
|
res.set('Content-Type', ct).set('Cache-Control', 'no-cache').send(buf);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// POST /api/icon-sets/:set — owner upload: multipart files (incl .zip) and/or { url }.
|
// POST /api/icon-sets/:set — owner upload: multipart files (incl .zip) and/or { url }.
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import { asyncWrap } from '../errors.js';
|
import { asyncWrap } from '../errors.js';
|
||||||
import { requireOwner } from '../cap.js';
|
import { requireOwner } from '../cap.js';
|
||||||
|
import { validate } from '../validate.js';
|
||||||
import * as repo from '../../db/repos/speedtest.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 { enqueue } from '../../jobs/queue.js';
|
||||||
|
import { setSpeedtestSchedule } from '../../cron/index.js';
|
||||||
export const router = Router();
|
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 DEFAULT_CFG = { interval_min: 60, threshold_down_mbps: 0 };
|
||||||
const id = await enqueue('speedtest', {});
|
async function getCfg() { return { ...DEFAULT_CFG, ...(await settings.get('speedtest', {})) }; }
|
||||||
res.status(202).json({ enqueued: id });
|
|
||||||
|
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 statusRepo from '../db/repos/service_status.js';
|
||||||
import * as services from '../db/repos/monitored_services.js';
|
import * as services from '../db/repos/monitored_services.js';
|
||||||
import { runDeviceScanCycle } from '../infra/scan_cycle.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() {
|
export function startCron() {
|
||||||
// Daily at 03:00 local time
|
// Daily at 03:00 local time
|
||||||
@@ -18,11 +38,10 @@ export function startCron() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hourly speedtest
|
// Speedtest — interval from the saved config (default 60 min), reschedulable.
|
||||||
cron.schedule('0 * * * *', async () => {
|
settings.get('speedtest', {})
|
||||||
try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); }
|
.then(cfg => setSpeedtestSchedule(cfg?.interval_min || 60))
|
||||||
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
|
.catch(e => { log.error({ err: e }, 'speedtest schedule init failed'); setSpeedtestSchedule(60); });
|
||||||
});
|
|
||||||
|
|
||||||
// Health checks every minute. NOTE: this runs checkAll() inline; the same
|
// 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
|
// probe+upsert logic is also exposed on-demand via the `health.check` pg-boss
|
||||||
|
|||||||
12
lib/db/migrations/026_backup_runs.sql
Normal file
12
lib/db/migrations/026_backup_runs.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- 026_backup_runs.sql
|
||||||
|
-- Offsite DR backup run history, fed by /usr/local/bin/offsite-backup.sh on CT 300
|
||||||
|
-- (Core-4 vzdump -> Farm/Won). Powers the Sacred Valley "Backups" card.
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
ran_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ok boolean NOT NULL DEFAULT true,
|
||||||
|
total_bytes bigint,
|
||||||
|
won_free_bytes bigint,
|
||||||
|
guests jsonb, -- [{vmid,name,bytes}]
|
||||||
|
duration_sec integer
|
||||||
|
);
|
||||||
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
7
lib/db/migrations/027_dashboard_geom.sql
Normal 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;
|
||||||
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;
|
||||||
|
}
|
||||||
21
lib/db/repos/backups.js
Normal file
21
lib/db/repos/backups.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { pool } from '../pool.js';
|
||||||
|
|
||||||
|
export async function record({ ok = true, total_bytes = null, won_free_bytes = null,
|
||||||
|
guests = null, duration_sec = null }) {
|
||||||
|
const { rows: [r] } = await pool.query(
|
||||||
|
`INSERT INTO backup_runs (ok, total_bytes, won_free_bytes, guests, duration_sec)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||||
|
[ok, total_bytes, won_free_bytes, guests ? JSON.stringify(guests) : null, duration_sec]);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function latest() {
|
||||||
|
const { rows: [r] } = await pool.query(
|
||||||
|
`SELECT * FROM backup_runs ORDER BY id DESC LIMIT 1`);
|
||||||
|
return r || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function count() {
|
||||||
|
const { rows: [r] } = await pool.query(`SELECT count(*)::int AS n FROM backup_runs`);
|
||||||
|
return r.n;
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
import { pool } from '../pool.js';
|
import { pool } from '../pool.js';
|
||||||
|
|
||||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
|
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
|
||||||
|
|
||||||
export async function get() {
|
export async function get() {
|
||||||
const { rows } = await pool.query(
|
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 };
|
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(
|
await pool.query(
|
||||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
|
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
|
||||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
|
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
|
||||||
ON CONFLICT (owner_key) DO UPDATE
|
ON CONFLICT (owner_key) DO UPDATE
|
||||||
SET card_order = EXCLUDED.card_order,
|
SET card_order = EXCLUDED.card_order,
|
||||||
hidden = EXCLUDED.hidden,
|
hidden = EXCLUDED.hidden,
|
||||||
sizes = EXCLUDED.sizes,
|
sizes = EXCLUDED.sizes,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
extras = EXCLUDED.extras,
|
||||||
updated_at = now()`,
|
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();
|
return get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,51 @@
|
|||||||
import { pool } from '../pool.js';
|
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(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`,
|
`INSERT INTO speedtest_results
|
||||||
[down_mbps, up_mbps, ping_ms]);
|
(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];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function history(limit = 30) {
|
export async function history(limit = 30) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
|
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
|
||||||
return rows;
|
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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import * as services from '../../db/repos/monitored_services.js';
|
import * as services from '../../db/repos/monitored_services.js';
|
||||||
|
import * as devices from '../../db/repos/lan_devices.js';
|
||||||
import { log } from '../../log.js';
|
import { log } from '../../log.js';
|
||||||
|
|
||||||
export const NAME = 'discover.lan';
|
export const NAME = 'discover.lan';
|
||||||
|
|
||||||
|
// Well-known homelab ports → likely service, so candidates get a real name.
|
||||||
|
const PORT_SVC = {
|
||||||
|
2424: 'Void', 5055: 'Overseerr', 6767: 'Bazarr', 7878: 'Radarr', 8006: 'Proxmox VE',
|
||||||
|
8096: 'Jellyfin', 8123: 'Home Assistant', 8265: 'Tdarr', 8384: 'Syncthing', 8989: 'Sonarr',
|
||||||
|
9000: 'Portainer', 9090: 'Cockpit', 9696: 'Prowlarr', 11434: 'Ollama', 19999: 'Netdata',
|
||||||
|
32400: 'Plex'
|
||||||
|
};
|
||||||
|
|
||||||
// Common homelab web/service ports to probe.
|
// Common homelab web/service ports to probe.
|
||||||
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
|
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
|
||||||
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
|
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
|
||||||
@@ -55,13 +64,18 @@ export async function handler(job) {
|
|||||||
// 1) TCP sweep → live host:ports
|
// 1) TCP sweep → live host:ports
|
||||||
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
|
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
|
||||||
|
|
||||||
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo)
|
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo).
|
||||||
|
// Cross-reference the Network Devices band so candidates are named by service+device.
|
||||||
|
const deviceByIp = Object.fromEntries(
|
||||||
|
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const { host, port } of open) {
|
for (const { host, port } of open) {
|
||||||
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
|
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
|
||||||
const url = `${scheme}://${host}:${port}`;
|
const url = `${scheme}://${host}:${port}`;
|
||||||
const probe = await _http(url);
|
const probe = await _http(url);
|
||||||
const name = (probe && probe.title) || `${host}:${port}`;
|
const dev = deviceByIp[host];
|
||||||
|
const svc = PORT_SVC[port] || (probe && probe.title) || null;
|
||||||
|
const name = svc ? (dev ? `${svc} · ${dev}` : svc) : (dev ? `${dev} :${port}` : `${host}:${port}`);
|
||||||
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
|
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
|
||||||
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
|
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
|
||||||
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });
|
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });
|
||||||
|
|||||||
@@ -6,18 +6,42 @@ const pexec = promisify(execFile);
|
|||||||
|
|
||||||
export const NAME = 'speedtest';
|
export const NAME = 'speedtest';
|
||||||
|
|
||||||
// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags
|
// Ookla CLI gives the full metric set (jitter, packet loss, server, ISP,
|
||||||
// here if the box has the Ookla `speedtest -f json` CLI instead.
|
// shareable result URL). Override the binary via SPEEDTEST_BIN if needed.
|
||||||
async function defaultRunner() {
|
const OOKLA_BIN = process.env.SPEEDTEST_BIN || 'ookla-speedtest';
|
||||||
const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 });
|
|
||||||
|
async function ooklaRunner() {
|
||||||
|
const { stdout } = await pexec(OOKLA_BIN,
|
||||||
|
['-f', 'json', '--accept-license', '--accept-gdpr'], { timeout: 120000 });
|
||||||
const j = JSON.parse(stdout);
|
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 function _setRunner(fn) { runner = fn; }
|
||||||
|
|
||||||
export async function handler(_job) {
|
export async function handler(_job) {
|
||||||
|
try {
|
||||||
const r = await runner();
|
const r = await runner();
|
||||||
await repo.record(r);
|
const saved = await repo.record(r);
|
||||||
log.info(r, 'speedtest recorded');
|
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",
|
"name": "void-server",
|
||||||
"version": "2.5.1",
|
"version": "2.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.5.1",
|
"version": "2.10.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.5.1",
|
"version": "2.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { emit, state } from './state.js';
|
|||||||
import { el, mount } from './dom.js';
|
import { el, mount } from './dom.js';
|
||||||
import { attachDropzone } from './components/dropzone.js';
|
import { attachDropzone } from './components/dropzone.js';
|
||||||
import { initChrome } from './components/chrome.js';
|
import { initChrome } from './components/chrome.js';
|
||||||
|
import { loadTheme } from './theme.js';
|
||||||
|
|
||||||
const VIEWS = {
|
const VIEWS = {
|
||||||
home: () => import('./views/home.js'),
|
home: () => import('./views/home.js'),
|
||||||
@@ -31,7 +32,8 @@ const VIEWS = {
|
|||||||
links: () => import('./views/links.js'),
|
links: () => import('./views/links.js'),
|
||||||
mirror: () => import('./views/mirror.js'),
|
mirror: () => import('./views/mirror.js'),
|
||||||
settings: () => import('./views/settings.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) {
|
async function renderView(ctx) {
|
||||||
@@ -79,6 +81,7 @@ async function init() {
|
|||||||
try { await api.get('/api/spaces'); }
|
try { await api.get('/api/spaces'); }
|
||||||
catch { /* api wrapper opens the modal on 401 */ }
|
catch { /* api wrapper opens the modal on 401 */ }
|
||||||
}
|
}
|
||||||
|
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||||
renderTopbar(document.getElementById('topbar'));
|
renderTopbar(document.getElementById('topbar'));
|
||||||
renderSidebar(document.getElementById('sidebar'));
|
renderSidebar(document.getElementById('sidebar'));
|
||||||
renderRightrail(document.getElementById('rightrail'));
|
renderRightrail(document.getElementById('rightrail'));
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function renderSidebar(root) {
|
|||||||
el('div', { class: 'sb-section' },
|
el('div', { class: 'sb-section' },
|
||||||
el('div', { class: 'sb-title' }, 'Navigate'),
|
el('div', { class: 'sb-title' }, 'Navigate'),
|
||||||
navItem('Sacred Valley', '/sacred-valley'),
|
navItem('Sacred Valley', '/sacred-valley'),
|
||||||
|
navItem('Speedtest', '/speedtest'),
|
||||||
navItem('Terminal', '/terminal'),
|
navItem('Terminal', '/terminal'),
|
||||||
navItem('Search', '/search'),
|
navItem('Search', '/search'),
|
||||||
inboxItem,
|
inboxItem,
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { el } from '../dom.js';
|
import { el } from '../dom.js';
|
||||||
|
|
||||||
// Builds the refined-B chrome shell and returns { root, body }. The card module
|
// 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) {
|
export function svCard(def) {
|
||||||
const body = el('div', { class: 'sv-card-body' });
|
const body = el('div', { class: 'sv-card-body' });
|
||||||
const root = el('div', {
|
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
|
||||||
class: 'sv-card', dataset: { cardId: def.id },
|
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
|
||||||
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
|
|
||||||
},
|
|
||||||
el('div', { class: 'sv-card-title' }, def.title),
|
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
return { root, body };
|
return { root, body };
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const ROUTES = [
|
|||||||
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
||||||
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
||||||
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
||||||
|
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },
|
||||||
{ name: 'home', re: /^\/?$/, keys: [] }
|
{ name: 'home', re: /^\/?$/, keys: [] }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
111
public/style.css
111
public/style.css
@@ -382,14 +382,9 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
/* reserved for a future agent-output phase — unused now:
|
/* reserved for a future agent-output phase — unused now:
|
||||||
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
||||||
}
|
}
|
||||||
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
|
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
|
||||||
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (1–12) */
|
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
|
||||||
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
|
#sv-cards { position: relative; width: 100%; min-height: 200px; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
.sv-card {
|
.sv-card {
|
||||||
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
|
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);
|
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);
|
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.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
|
||||||
.sv-card.drag-over { border-color: var(--accent); }
|
.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 {
|
.sv-card-title {
|
||||||
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
||||||
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
||||||
@@ -572,6 +570,18 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
.dv-tile { position: relative; }
|
.dv-tile { position: relative; }
|
||||||
.dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
|
.dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
|
||||||
.dv-tile:hover .dv-edit-btn { opacity: 1; }
|
.dv-tile:hover .dv-edit-btn { opacity: 1; }
|
||||||
|
/* touch devices have no hover — keep the ✎ edit button always visible there */
|
||||||
|
@media (hover: none) { .dv-edit-btn { opacity: .85; } }
|
||||||
|
/* Little Blue service-tile edit affordance */
|
||||||
|
.lb-tile-wrap { position: relative; }
|
||||||
|
.lb-edit-btn { position: absolute; top: 5px; right: 5px; z-index: 5; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
|
||||||
|
.lb-tile-wrap:hover .lb-edit-btn { opacity: 1; }
|
||||||
|
.lb-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||||
|
@media (hover: none) { .lb-edit-btn { opacity: .85; } }
|
||||||
|
.lb-edit { display: flex; flex-direction: column; gap: 4px; padding: 8px; }
|
||||||
|
.lb-edit .dv-edit-name, .lb-edit .dv-edit-grp { width: 100%; margin: 0; }
|
||||||
|
.lb-edit-btns { display: flex; gap: 4px; margin-top: 2px; }
|
||||||
|
.lb-edit-btns button { font-size: 11px; padding: 2px 8px; }
|
||||||
.dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
|
.dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||||
.dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; }
|
.dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; }
|
||||||
.dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; }
|
.dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; }
|
||||||
@@ -614,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; }
|
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-edit { display: flex; }
|
||||||
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
|
#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-grip:active { cursor: grabbing; }
|
||||||
.sv-ed-sizes { display: flex; gap: 2px; }
|
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||||
.sv-ed-size, .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; }
|
||||||
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
|
.sv-ed-free { color: var(--muted); }
|
||||||
.sv-ed-size { color: var(--muted); }
|
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||||
.sv-ed-size: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-card[data-size="s"] .sv-ed-size[data-s="s"],
|
.sv-ed-hide { color: var(--bad); }
|
||||||
.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-hide:hover { background: var(--bad); color: var(--bg); }
|
.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;
|
#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; }
|
border: 1px dashed var(--border); border-radius: 8px; }
|
||||||
@@ -633,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;
|
.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; }
|
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-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; }
|
.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;
|
||||||
|
}
|
||||||
50
public/views/cards/backups.js
Normal file
50
public/views/cards/backups.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// public/views/cards/backups.js — offsite DR backup status (Core-4 -> Farm/Won).
|
||||||
|
// Fed by /usr/local/bin/offsite-backup.sh which POSTs each run to /api/backups.
|
||||||
|
import { el, mount } from '../../dom.js';
|
||||||
|
import { api } from '../../api.js';
|
||||||
|
|
||||||
|
let body, timer;
|
||||||
|
|
||||||
|
const gb = b => (b == null ? '–'
|
||||||
|
: b >= 1e12 ? (b / 1e12).toFixed(1) + 'T'
|
||||||
|
: b >= 1e9 ? (b / 1e9).toFixed(1) + 'G'
|
||||||
|
: Math.round(b / 1e6) + 'M');
|
||||||
|
function ago(ts) {
|
||||||
|
const s = Math.max(0, (Date.now() - Date.parse(ts)) / 1000);
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||||
|
return Math.floor(s / 86400) + 'd';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!body) return;
|
||||||
|
try {
|
||||||
|
const d = await api.get('/api/backups');
|
||||||
|
const r = d.latest;
|
||||||
|
if (!r) { mount(body, el('span', { class: 'muted' }, 'No offsite backups yet.')); return; }
|
||||||
|
const stale = (Date.now() - Date.parse(r.ran_at)) > 8 * 86400000; // >8d overdue
|
||||||
|
const status = (!r.ok || stale) ? 'bad' : 'ok';
|
||||||
|
const kids = [];
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Last run'),
|
||||||
|
el('span', { class: 'cl-badge ' + status }, r.ok ? ago(r.ran_at) + ' ago' : 'FAILED')));
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Pushed to Farm'), el('span', {}, gb(r.total_bytes))));
|
||||||
|
for (const g of (r.guests || []))
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'CT ' + g.vmid + ' ' + g.name),
|
||||||
|
el('span', { class: 'muted' }, gb(g.bytes))));
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Farm free'), el('span', {}, gb(r.won_free_bytes))));
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Schedule'), el('span', { class: 'muted' }, d.schedule || 'weekly')));
|
||||||
|
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
|
||||||
|
} catch { mount(body, el('span', { class: 'muted' }, 'Backups unavailable')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'backups', title: 'Backups · offsite', size: 's',
|
||||||
|
mount(e) { body = e; load(); },
|
||||||
|
start() { timer = setInterval(load, 60000); },
|
||||||
|
stop() { clearInterval(timer); body = null; }
|
||||||
|
};
|
||||||
114
public/views/cards/blackflame.js
Normal file
114
public/views/cards/blackflame.js
Normal 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; }
|
||||||
|
};
|
||||||
|
}
|
||||||
16
public/views/cards/blank.js
Normal file
16
public/views/cards/blank.js
Normal 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() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { el, mount } from '../../dom.js';
|
||||||
import { api } from '../../api.js';
|
import { api } from '../../api.js';
|
||||||
|
|
||||||
@@ -6,17 +6,21 @@ let body;
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
try {
|
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 latest = hist[0];
|
||||||
const max = Math.max(1, ...hist.map(h => Number(h.down_mbps)));
|
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 =>
|
hist.slice(0, 30).reverse().map(h =>
|
||||||
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
|
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
|
||||||
height: (Number(h.down_mbps) / max * 100) + '%' } })));
|
height: (Number(h.down_mbps) / max * 100) + '%' } })));
|
||||||
mount(body,
|
mount(body,
|
||||||
el('div', { class: 'sv-row', style: { fontSize: '20px' } },
|
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')),
|
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);
|
bars);
|
||||||
} catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
|
} catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js';
|
|||||||
import { isRemoteHost } from './service_url.js';
|
import { isRemoteHost } from './service_url.js';
|
||||||
|
|
||||||
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
||||||
|
const CATS = ['agents', 'infrastructure', 'media', 'other'];
|
||||||
let host, timer, scanning = false;
|
let host, timer, scanning = false;
|
||||||
|
|
||||||
async function promote(id) {
|
async function promote(id) {
|
||||||
@@ -17,6 +18,36 @@ function scan() {
|
|||||||
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
|
||||||
|
function editForm(s) {
|
||||||
|
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
|
||||||
|
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
|
||||||
|
catS.value = s.category || 'other';
|
||||||
|
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
|
||||||
|
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
|
||||||
|
const save = el('button', { class: 'dv-add' }, 'Save');
|
||||||
|
save.onclick = async () => {
|
||||||
|
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
|
||||||
|
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
|
||||||
|
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
|
||||||
|
};
|
||||||
|
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||||
|
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
|
||||||
|
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||||
|
cancel.onclick = load;
|
||||||
|
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
|
||||||
|
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
|
||||||
|
function tileWithEdit(s, remote) {
|
||||||
|
const wrap = el('div', { class: 'lb-tile-wrap' });
|
||||||
|
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
|
||||||
|
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
|
||||||
|
mount(wrap, serviceTile(s, remote), edit);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
||||||
async function discoveredSection() {
|
async function discoveredSection() {
|
||||||
let cand;
|
let cand;
|
||||||
@@ -30,8 +61,8 @@ async function discoveredSection() {
|
|||||||
el('div', { class: 'tiles' }, cand.map(c =>
|
el('div', { class: 'tiles' }, cand.map(c =>
|
||||||
el('div', { class: 'tile disc' },
|
el('div', { class: 'tile disc' },
|
||||||
el('div', { class: 'tile-main' },
|
el('div', { class: 'tile-main' },
|
||||||
el('div', { class: 'tile-nm' }, c.name),
|
el('div', { class: 'tile-nm' }, c.device || c.name),
|
||||||
el('div', { class: 'tile-host' }, c.url)),
|
el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
|
||||||
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +77,7 @@ async function load() {
|
|||||||
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
||||||
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
||||||
el('span', { class: 'line' })),
|
el('span', { class: 'line' })),
|
||||||
el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))));
|
el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote)))));
|
||||||
const disc = await discoveredSection();
|
const disc = await discoveredSection();
|
||||||
mount(host,
|
mount(host,
|
||||||
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { api } from '../api.js';
|
|||||||
import { renderHealthBand, stopHealthBand } from './health_band.js';
|
import { renderHealthBand, stopHealthBand } from './health_band.js';
|
||||||
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
|
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
|
||||||
import { svCard } from '../components/sv_card.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 clock from './cards/clock.js';
|
||||||
import weather from './cards/weather.js';
|
import weather from './cards/weather.js';
|
||||||
import hostPerf from './cards/host_perf.js';
|
import hostPerf from './cards/host_perf.js';
|
||||||
@@ -15,127 +13,229 @@ import speedtest from './cards/speedtest.js';
|
|||||||
import aiUsage from './cards/ai_usage.js';
|
import aiUsage from './cards/ai_usage.js';
|
||||||
import cluster from './cards/cluster.js';
|
import cluster from './cards/cluster.js';
|
||||||
import storage from './cards/storage.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, jobs, inbox, search, speedtest, aiUsage];
|
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 active = []; // mounted cards needing stop()
|
||||||
let renderGen = 0; // guards overlapping async renders
|
let renderGen = 0; // guards overlapping async renders
|
||||||
let editing = false;
|
let editing = false;
|
||||||
let layout = { card_order: [], hidden: [], sizes: {} };
|
let mainEl;
|
||||||
|
let layout = { hidden: [], geom: {}, extras: [] };
|
||||||
|
|
||||||
const grid = () => document.getElementById('sv-cards');
|
const grid = () => document.getElementById('sv-cards');
|
||||||
|
|
||||||
async function saveLayout() {
|
function defFor(extra) {
|
||||||
try { await api.put('/api/dashboard/layout', layout); }
|
if (extra.type === 'blank') return blankCard(extra.id);
|
||||||
catch (e) { console.error('save layout', e); }
|
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
|
function visibleDefs() {
|
||||||
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
|
const hidden = new Set(layout.hidden || []);
|
||||||
function spanOf(def) {
|
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
|
||||||
const v = layout.sizes?.[def.id];
|
const extras = (layout.extras || []).map(defFor).filter(Boolean);
|
||||||
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
|
return [...builtins, ...extras];
|
||||||
if (typeof v === 'string') return STR_SPAN[v] || 6;
|
|
||||||
return STR_SPAN[def.size] || 6;
|
|
||||||
}
|
}
|
||||||
function curSpan(id) {
|
|
||||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
function defaultSize(def) {
|
||||||
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
|
if (def.type === 'blackflame') return { w: 6, h: 10 };
|
||||||
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
|
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));
|
function geomOf(def) {
|
||||||
layout.sizes = { ...layout.sizes, [id]: span };
|
const g = (layout.geom || {})[def.id];
|
||||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
if (g) return g;
|
||||||
if (node) {
|
const { w, h } = defaultSize(def);
|
||||||
node.style.gridColumn = 'span ' + span;
|
return { x: 0, y: 0, w, h };
|
||||||
const lbl = node.querySelector('.sv-span-val');
|
|
||||||
if (lbl) lbl.textContent = span;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
saveLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editOverlay(def) {
|
function editOverlay(def) {
|
||||||
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
|
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
|
||||||
const stepper = el('span', { class: 'sv-ed-span' },
|
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
|
||||||
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, '−'),
|
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '⤢');
|
||||||
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
|
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
|
||||||
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
|
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
|
||||||
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
|
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
|
||||||
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
|
const frag = document.createDocumentFragment();
|
||||||
|
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
|
||||||
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountOne(def) {
|
function mountOne(def) {
|
||||||
const span = spanOf(def);
|
const { root, body } = svCard(def);
|
||||||
const { root, body } = svCard({ ...def, span });
|
if (def.decorative) root.classList.add('sv-card-decor');
|
||||||
root.appendChild(editOverlay(def));
|
root.appendChild(editOverlay(def));
|
||||||
|
applyGeom(root, geomOf(def));
|
||||||
grid().appendChild(root);
|
grid().appendChild(root);
|
||||||
try { def.mount(body); def.start && def.start(); active.push(def); }
|
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); }
|
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSize(id, s) {
|
function placeNew(def) {
|
||||||
layout.sizes = { ...layout.sizes, [id]: s };
|
let maxBottom = 0;
|
||||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
|
||||||
if (node) node.dataset.size = s;
|
const { w, h } = defaultSize(def);
|
||||||
saveLayout();
|
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCard(id) {
|
function addBuiltin(id) {
|
||||||
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
|
layout.hidden = (layout.hidden || []).filter(x => x !== id);
|
||||||
const def = BY_ID.get(id);
|
const def = BUILTIN_BY_ID.get(id);
|
||||||
if (def?.stop) def.stop();
|
if (!def) return;
|
||||||
active = active.filter(d => d.id !== id);
|
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||||
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
|
|
||||||
renderTray();
|
|
||||||
saveLayout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCard(id) {
|
function addDecor(type) {
|
||||||
layout.hidden = layout.hidden.filter(x => x !== id);
|
const id = `${type}-${Date.now().toString(36)}`;
|
||||||
const def = BY_ID.get(id);
|
const def = defFor({ id, type });
|
||||||
if (def) { mountOne(def); wireDrag(); }
|
if (!def) return;
|
||||||
renderTray();
|
layout.extras = [...(layout.extras || []), { id, type }];
|
||||||
saveLayout();
|
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReorder(newOrder) {
|
function removeCard(def) {
|
||||||
const frag = document.createDocumentFragment();
|
const d = active.find(a => a.id === def.id);
|
||||||
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
|
if (d && d.stop) d.stop();
|
||||||
grid().appendChild(frag);
|
active = active.filter(a => a.id !== def.id);
|
||||||
layout.card_order = newOrder;
|
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
|
||||||
saveLayout();
|
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();
|
||||||
// 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 renderTray() {
|
function renderTray() {
|
||||||
const tray = document.getElementById('sv-tray');
|
const tray = document.getElementById('sv-tray');
|
||||||
if (!tray) return;
|
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,
|
mount(tray,
|
||||||
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
|
el('span', { class: 'sv-tray-label' }, 'Add card:'),
|
||||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
|
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';
|
tray.style.display = editing ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +249,8 @@ function toggleEdit() {
|
|||||||
renderTray();
|
renderTray();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainEl;
|
|
||||||
async function resetLayout() {
|
async function resetLayout() {
|
||||||
layout = { card_order: [], hidden: [], sizes: {} };
|
layout = { hidden: [], geom: {}, extras: [] };
|
||||||
await saveLayout();
|
await saveLayout();
|
||||||
render(mainEl);
|
render(mainEl);
|
||||||
}
|
}
|
||||||
@@ -174,12 +273,21 @@ export async function render(main) {
|
|||||||
el('div', { id: 'sv-devices' })
|
el('div', { id: 'sv-devices' })
|
||||||
);
|
);
|
||||||
|
|
||||||
layout = { card_order: [], hidden: [], sizes: {} };
|
layout = { hidden: [], geom: {}, extras: [] };
|
||||||
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
|
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;
|
if (myGen !== renderGen) return;
|
||||||
|
|
||||||
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
|
const defs = visibleDefs();
|
||||||
wireDrag();
|
autoPlaceMissing(defs);
|
||||||
|
for (const def of defs) mountOne(def);
|
||||||
|
relayout();
|
||||||
|
renderTray();
|
||||||
|
window.removeEventListener('resize', relayout);
|
||||||
|
window.addEventListener('resize', relayout);
|
||||||
|
|
||||||
renderHealthBand(document.getElementById('sv-health'));
|
renderHealthBand(document.getElementById('sv-health'));
|
||||||
renderDevicesBand(document.getElementById('sv-devices'));
|
renderDevicesBand(document.getElementById('sv-devices'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,57 @@
|
|||||||
import { el, mount } from '../dom.js';
|
import { el, mount } from '../dom.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { iconSetsPanel } from './icon_sets_panel.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) {
|
function section(title, sub, bodyEl) {
|
||||||
return el('div', { class: 'card settings-card' },
|
return el('div', { class: 'card settings-card' },
|
||||||
@@ -120,6 +171,7 @@ export async function render(main) {
|
|||||||
|
|
||||||
mount(main,
|
mount(main,
|
||||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
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('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('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),
|
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);
|
||||||
|
}
|
||||||
@@ -14,8 +14,12 @@ import { seedFromConfig } from './lib/health/registry.js';
|
|||||||
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
||||||
import { handleMcp } from './lib/mcp/http.js';
|
import { handleMcp } from './lib/mcp/http.js';
|
||||||
import httpProxy from 'http-proxy';
|
import httpProxy from 'http-proxy';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
const VERSION = '2.5.1';
|
// Read the version from package.json so a deploy never serves a stale /health
|
||||||
|
// version (the old hardcoded const had to be bumped by hand and caused the
|
||||||
|
// health-gated deploy to roll back 3x when forgotten).
|
||||||
|
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version;
|
||||||
|
|
||||||
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
|
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
|
||||||
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
|
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
|
||||||
|
|||||||
21
tests/api/backups.test.js
Normal file
21
tests/api/backups.test.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ingest } from '../../lib/api/routes/backups.js';
|
||||||
|
|
||||||
|
describe('backups ingest schema', () => {
|
||||||
|
it('accepts a valid run', () => {
|
||||||
|
const r = ingest.safeParse({
|
||||||
|
ok: true, total_bytes: 2400000000, won_free_bytes: 33000000000,
|
||||||
|
guests: [{ vmid: 310, name: 'void-db', bytes: 518000000 }], duration_sec: 950
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
it('accepts an empty body (all fields optional)', () => {
|
||||||
|
expect(ingest.safeParse({}).success).toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects negative bytes', () => {
|
||||||
|
expect(ingest.safeParse({ total_bytes: -5 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it('rejects malformed guests', () => {
|
||||||
|
expect(ingest.safeParse({ guests: [{ vmid: 1 }] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user