Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ac261862 | ||
|
|
15de56dbe6 | ||
|
|
442bb6ccc9 | ||
|
|
ea20c55917 | ||
|
|
4ef7fa2d75 | ||
|
|
b17cdb7f77 | ||
|
|
b967c0bfdd |
@@ -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
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' });
|
||||||
|
}));
|
||||||
@@ -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() });
|
||||||
|
|||||||
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
|
||||||
|
);
|
||||||
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,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 });
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.5.2",
|
"version": "2.6.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.5.2",
|
"version": "2.6.6",
|
||||||
"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.2",
|
"version": "2.6.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -572,6 +572,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; }
|
||||||
|
|||||||
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; }
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.2';
|
// 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