Compare commits
4 Commits
b16456fc1b
...
v2.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b17cdb7f77 | ||
|
|
b967c0bfdd | ||
|
|
16e324102e | ||
|
|
18eba2d911 |
@@ -35,6 +35,7 @@ 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';
|
||||||
|
|
||||||
export function mountApi(app) {
|
export function mountApi(app) {
|
||||||
@@ -52,6 +53,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);
|
||||||
|
|||||||
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' });
|
||||||
|
}));
|
||||||
@@ -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 }.
|
||||||
|
|||||||
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
|
||||||
|
);
|
||||||
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;
|
||||||
|
}
|
||||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.5.0",
|
"version": "2.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.5.0",
|
"version": "2.6.1",
|
||||||
"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.0",
|
"version": "2.6.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ unicode: "ea54"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 560 B After Width: | Height: | Size: 555 B |
@@ -10,7 +10,7 @@ unicode: "f1d2"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 629 B |
@@ -10,7 +10,7 @@ unicode: "ea89"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 528 B |
@@ -10,7 +10,7 @@ unicode: "eb64"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 487 B After Width: | Height: | Size: 482 B |
@@ -10,7 +10,7 @@ unicode: "ea88"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 492 B |
@@ -10,7 +10,7 @@ unicode: "ea8a"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 510 B After Width: | Height: | Size: 505 B |
@@ -10,7 +10,7 @@ unicode: "ebd9"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 551 B |
@@ -10,7 +10,7 @@ unicode: "eb0e"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 604 B After Width: | Height: | Size: 599 B |
@@ -10,7 +10,7 @@ unicode: "eb18"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 622 B After Width: | Height: | Size: 617 B |
@@ -10,7 +10,7 @@ unicode: "eb1f"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 589 B |
@@ -10,7 +10,7 @@ unicode: "ed61"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 599 B After Width: | Height: | Size: 594 B |
@@ -10,7 +10,7 @@ unicode: "ea8c"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 508 B |
@@ -10,7 +10,7 @@ unicode: "ea8d"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 470 B |
@@ -10,7 +10,7 @@ version: "3.5"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 572 B After Width: | Height: | Size: 567 B |
@@ -10,7 +10,7 @@ unicode: "ebf9"
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="#e8e6ed"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 489 B |
@@ -647,14 +647,14 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
.sv-cluster .st-fill.bad { background: var(--bad); }
|
.sv-cluster .st-fill.bad { background: var(--bad); }
|
||||||
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
|
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
.dv-icon { width: 20px; height: 20px; object-fit: contain; opacity: .9; }
|
.dv-icon { width: 30px; height: 30px; object-fit: contain; opacity: .95; }
|
||||||
.dv-icon-fb { width: 20px; height: 20px; display: grid; place-items: center; font-size: 11px; background: var(--panel-2, #1b1b22); border-radius: 4px; }
|
.dv-icon-fb { width: 30px; height: 30px; display: grid; place-items: center; font-size: 14px; color: var(--text); background: var(--panel-2, #1b1b22); border-radius: 4px; }
|
||||||
.dv-seen { font-size: 11px; color: var(--muted, #8a8a94); }
|
.dv-seen { font-size: 11px; color: var(--muted, #8a8a94); }
|
||||||
.icon-picker { border: 1px solid var(--border, #2a2a36); border-radius: 6px; padding: 6px; margin-top: 6px; max-width: 320px; }
|
.icon-picker { border: 1px solid var(--border, #2a2a36); border-radius: 6px; padding: 6px; margin-top: 6px; max-width: 320px; }
|
||||||
.ip-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
.ip-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
.ip-tab.active { color: var(--accent, #ff4f2e); border-bottom: 1px solid var(--accent, #ff4f2e); }
|
.ip-tab.active { color: var(--accent, #ff4f2e); border-bottom: 1px solid var(--accent, #ff4f2e); }
|
||||||
.ip-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
.ip-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.ip-icon { width: 34px; height: 34px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
|
.ip-icon { width: 40px; height: 40px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
|
||||||
.ip-icon img { width: 22px; height: 22px; object-fit: contain; }
|
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||||
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||||
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
|||||||
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; }
|
||||||
|
};
|
||||||
@@ -15,8 +15,9 @@ 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';
|
||||||
|
|
||||||
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 BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||||
|
|
||||||
let active = []; // mounted cards needing stop()
|
let active = []; // mounted cards needing stop()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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';
|
||||||
|
|
||||||
const VERSION = '2.5.0';
|
const VERSION = '2.6.1';
|
||||||
|
|
||||||
// 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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||