4 Commits

Author SHA1 Message Date
root
4ef7fa2d75 fix(health): derive /health version from package.json (kills the manual server.js bump gotcha)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:58:44 +10:00
root
b17cdb7f77 fix(sv): Backups card byte formatter — tenths for GB, MB under 1G
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:49:00 +10:00
root
b967c0bfdd feat(sv): Backups card — offsite DR status (Core-4 -> Farm) + /api/backups (2.6.0)
migration 026 backup_runs; POST ingest (owner) from offsite-backup.sh, GET for the
Sacred Valley card showing last run, per-guest sizes, Farm free, schedule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:47:17 +10:00
root
16e324102e fix(icons): serve icons no-cache so updates propagate (2.5.2)
Icon route used Cache-Control: public, max-age=86400, so changed icons stayed
stuck in CF + browser caches for a day. Switch to no-cache (revalidate; Express
ETag => 304 when unchanged) so icon edits show up immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:25:54 +10:00
11 changed files with 150 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ import { router as aiUsageRouter } from './routes/ai_usage.js';
import { router as infraRouter } from './routes/infra.js';
import { router as clusterRouter } from './routes/cluster.js';
import { router as storageRouter } from './routes/storage.js';
import { router as backupsRouter } from './routes/backups.js';
import { router as kuttRouter } from './routes/kutt.js';
export function mountApi(app) {
@@ -52,6 +53,7 @@ export function mountApi(app) {
api.use('/infra', infraRouter);
api.use('/cluster', clusterRouter);
api.use('/storage', storageRouter);
api.use('/backups', backupsRouter);
api.use('/little-blue', littleblueRouter);
api.use('/ai-usage', aiUsageRouter);
api.use('/projects', projectsRouter);

30
lib/api/routes/backups.js Normal file
View 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' });
}));

View File

@@ -20,7 +20,10 @@ router.get('/:set/:file', asyncWrap(async (req, res) => {
try { buf = await sets.readIcon(req.params.set, req.params.file); }
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
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 }.

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

21
lib/db/repos/backups.js Normal file
View 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;
}

4
package-lock.json generated
View File

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

View File

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

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

View File

@@ -15,8 +15,9 @@ import speedtest from './cards/speedtest.js';
import aiUsage from './cards/ai_usage.js';
import cluster from './cards/cluster.js';
import storage from './cards/storage.js';
import backups from './cards/backups.js';
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]));
let active = []; // mounted cards needing stop()

View File

@@ -14,8 +14,12 @@ import { seedFromConfig } from './lib/health/registry.js';
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js';
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
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the

21
tests/api/backups.test.js Normal file
View 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);
});
});