From b967c0bfdd2359aabc35bad6ae1032feda50adfe Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 17:47:17 +1000 Subject: [PATCH] =?UTF-8?q?feat(sv):=20Backups=20card=20=E2=80=94=20offsit?= =?UTF-8?q?e=20DR=20status=20(Core-4=20->=20Farm)=20+=20/api/backups=20(2.?= =?UTF-8?q?6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/api/index.js | 2 ++ lib/api/routes/backups.js | 30 +++++++++++++++++ lib/db/migrations/026_backup_runs.sql | 12 +++++++ lib/db/repos/backups.js | 21 ++++++++++++ package-lock.json | 4 +-- package.json | 2 +- public/views/cards/backups.js | 47 +++++++++++++++++++++++++++ public/views/sacred_valley.js | 3 +- server.js | 2 +- tests/api/backups.test.js | 21 ++++++++++++ 10 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 lib/api/routes/backups.js create mode 100644 lib/db/migrations/026_backup_runs.sql create mode 100644 lib/db/repos/backups.js create mode 100644 public/views/cards/backups.js create mode 100644 tests/api/backups.test.js diff --git a/lib/api/index.js b/lib/api/index.js index 7f020c8..4730b16 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -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); diff --git a/lib/api/routes/backups.js b/lib/api/routes/backups.js new file mode 100644 index 0000000..4b9f76b --- /dev/null +++ b/lib/api/routes/backups.js @@ -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' }); +})); diff --git a/lib/db/migrations/026_backup_runs.sql b/lib/db/migrations/026_backup_runs.sql new file mode 100644 index 0000000..9d1dbc7 --- /dev/null +++ b/lib/db/migrations/026_backup_runs.sql @@ -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 +); diff --git a/lib/db/repos/backups.js b/lib/db/repos/backups.js new file mode 100644 index 0000000..d019af3 --- /dev/null +++ b/lib/db/repos/backups.js @@ -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; +} diff --git a/package-lock.json b/package-lock.json index a113193..0622d02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "void-server", - "version": "2.5.2", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "void-server", - "version": "2.5.2", + "version": "2.6.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", diff --git a/package.json b/package.json index 8a33f8c..58a94aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.5.2", + "version": "2.6.0", "type": "module", "private": true, "scripts": { diff --git a/public/views/cards/backups.js b/public/views/cards/backups.js new file mode 100644 index 0000000..94d8b61 --- /dev/null +++ b/public/views/cards/backups.js @@ -0,0 +1,47 @@ +// 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' : Math.round(b / 1e9) + 'G'); +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; } +}; diff --git a/public/views/sacred_valley.js b/public/views/sacred_valley.js index 36e406b..f91733a 100644 --- a/public/views/sacred_valley.js +++ b/public/views/sacred_valley.js @@ -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() diff --git a/server.js b/server.js index c1527b6..2e4f4ca 100644 --- a/server.js +++ b/server.js @@ -15,7 +15,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; import { handleMcp } from './lib/mcp/http.js'; import httpProxy from 'http-proxy'; -const VERSION = '2.5.2'; +const VERSION = '2.6.0'; // 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 diff --git a/tests/api/backups.test.js b/tests/api/backups.test.js new file mode 100644 index 0000000..5a8f43e --- /dev/null +++ b/tests/api/backups.test.js @@ -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); + }); +});