From ce26895d8e67c7d8dc503bf3ce8233b4cb202273 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 07:55:08 +1000 Subject: [PATCH] =?UTF-8?q?feat:=202.0.0-alpha.11=20=E2=80=94=20DB-backed?= =?UTF-8?q?=20service=20registry=20+=20LAN=20auto-discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - monitored_services table (mig 015) replaces config/services.json (now a boot seed) - owner CRUD over /api/health/services; GET is DB-backed; cron+worker read the DB - discover.lan worker: pure-Node TCP sweep + HTTP-title probe -> disabled 'discovered' candidates (never clobbers curated entries); POST /api/health/discover + GET .../discovered - dashboard: Scan button + Discovered(N) section with one-click promote Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++ lib/api/routes/health.js | 60 ++++++++++++-- lib/cron/index.js | 4 +- lib/db/migrations/015_monitored_services.sql | 20 +++++ lib/db/repos/monitored_services.js | 85 ++++++++++++++++++++ lib/health/registry.js | 27 +++++-- lib/jobs/index.js | 3 +- lib/jobs/workers/discover.js | 72 +++++++++++++++++ lib/jobs/workers/health_check.js | 4 +- package.json | 2 +- public/style.css | 9 +++ public/views/health_band.js | 41 +++++++++- server.js | 6 +- tests/api/health.test.js | 53 +++++++++--- tests/health/registry.test.js | 32 ++++++-- tests/jobs/discover.test.js | 40 +++++++++ tests/repos/monitored_services.test.js | 48 +++++++++++ 17 files changed, 466 insertions(+), 46 deletions(-) create mode 100644 lib/db/migrations/015_monitored_services.sql create mode 100644 lib/db/repos/monitored_services.js create mode 100644 lib/jobs/workers/discover.js create mode 100644 tests/jobs/discover.test.js create mode 100644 tests/repos/monitored_services.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f13b784..875e606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). +## 2.0.0-alpha.11 — DB-backed service registry + LAN auto-discovery +- The health-band registry is now in Postgres (`monitored_services`, migration 015) instead of the hand-edited `config/services.json` — which becomes a one-time boot seed (auto-populated if the table is empty). +- Owner CRUD over the registry: `POST/PATCH/DELETE /api/health/services` (add/edit/enable/disable/remove); `GET /api/health/services` is now DB-backed. +- LAN auto-discovery: `discover.lan` pg-boss worker (pure-Node TCP sweep + HTTP-title probe, no nmap) + `POST /api/health/discover`. Found host:ports become **disabled `discovered` candidates** that never clobber curated entries; `GET /api/health/services/discovered` lists them. +- Dashboard: a "Scan" button + a "Discovered (N new)" section in Little Blue's band, with one-click promote. + ## 2.0.0-alpha.10 — Cloudflare Access SSO as owner auth - Browser requests through the CF tunnel no longer need the owner token copied onto each device: a cryptographically-verified Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) for an allow-listed email now counts as the owner (`lib/auth/cf_access.js`, wired into `agentOrOwner`). - Security: verifies signature against the team JWKS + audience (app AUD) + email allow-list; the plain email header is never trusted alone. Fails closed → falls back to the owner token (LAN-direct `:3000` path and dev/tests unaffected). diff --git a/lib/api/routes/health.js b/lib/api/routes/health.js index f71ae6c..2462728 100644 --- a/lib/api/routes/health.js +++ b/lib/api/routes/health.js @@ -1,16 +1,20 @@ import { Router } from 'express'; +import { z } from 'zod'; import { asyncWrap } from '../errors.js'; import { requireOwner } from '../cap.js'; -import { load, grouped, iconSlug } from '../../health/registry.js'; +import { validate } from '../validate.js'; +import { grouped, iconSlug } from '../../health/registry.js'; +import * as services from '../../db/repos/monitored_services.js'; import * as statusRepo from '../../db/repos/service_status.js'; import { enqueue } from '../../jobs/queue.js'; export const router = Router(); +// GET /services — grouped health band (DB-backed registry + cached status). router.get('/services', asyncWrap(async (_req, res) => { const statuses = Object.fromEntries((await statusRepo.all()).map(s => [s.service_id, s])); - const groups = grouped(load()).map(g => { - const services = g.services.map(s => { + const groups = grouped(await services.listEnabled()).map(g => { + const list = g.services.map(s => { const st = statuses[s.id]; return { id: s.id, name: s.name, host: s.host, url: s.url, icon: iconSlug(s), @@ -18,13 +22,53 @@ router.get('/services', asyncWrap(async (_req, res) => { detail: st?.detail || null, checked_at: st?.checked_at || null }; }); - return { category: g.category, healthy: services.filter(s => s.status === 'ok').length, - total: services.length, services }; + return { category: g.category, healthy: list.filter(s => s.status === 'ok').length, total: list.length, services: list }; }); res.json(groups); })); -router.post('/check', requireOwner, asyncWrap(async (_req, res) => { - const id = await enqueue('health.check', {}); - res.status(202).json({ enqueued: id }); +// GET /services/discovered — candidates from a LAN scan, awaiting review (owner). +router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => { + res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) }))); +})); + +const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() }); +const svcBody = z.object({ + id: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/), + name: z.string().min(1).max(120), + category: z.enum(['agents', 'infrastructure', 'media', 'other']).default('other'), + host: z.string().max(120).optional(), + url: z.string().url(), + icon: z.string().max(64).optional(), + check: checkCfg.optional() +}); +const patchBody = svcBody.omit({ id: true }).partial().extend({ enabled: z.boolean().optional() }); +const idParam = z.object({ id: z.string().regex(/^[a-z0-9-]+$/) }); + +// POST /services — add a manual service (owner). +router.post('/services', requireOwner, validate({ body: svcBody }), asyncWrap(async (req, res) => { + res.status(201).json(await services.create({ ...req.body, source: 'manual', enabled: true })); +})); + +// PATCH /services/:id — edit / enable (promote a discovered candidate) (owner). +router.patch('/services/:id', requireOwner, validate({ params: idParam, body: patchBody }), asyncWrap(async (req, res) => { + const updated = await services.update(req.params.id, req.body); + if (!updated) return res.status(404).json({ error: { code: 'not_found', message: 'service not found' } }); + res.json(updated); +})); + +// DELETE /services/:id — remove (owner). +router.delete('/services/:id', requireOwner, validate({ params: idParam }), asyncWrap(async (req, res) => { + if (!(await services.remove(req.params.id))) return res.status(404).json({ error: { code: 'not_found' } }); + res.status(204).end(); +})); + +// POST /check — immediate health pass (owner). +router.post('/check', requireOwner, asyncWrap(async (_req, res) => { + res.status(202).json({ enqueued: await enqueue('health.check', {}) }); +})); + +// POST /discover — kick off a LAN discovery scan (owner). +router.post('/discover', requireOwner, asyncWrap(async (_req, res) => { + res.status(202).json({ enqueued: await enqueue('discover.lan', {}) }); })); diff --git a/lib/cron/index.js b/lib/cron/index.js index b9b7383..03f7be2 100644 --- a/lib/cron/index.js +++ b/lib/cron/index.js @@ -2,9 +2,9 @@ import cron from 'node-cron'; import { runSync } from './sync_source_docs.js'; import { log } from '../log.js'; import { enqueue } from '../jobs/queue.js'; -import { load } from '../health/registry.js'; import { checkAll } from '../health/checker.js'; import * as statusRepo from '../db/repos/service_status.js'; +import * as services from '../db/repos/monitored_services.js'; export function startCron() { // Daily at 03:00 local time @@ -29,7 +29,7 @@ export function startCron() { // Keep the two in sync — both rely on lib/health/checker.js as the source of truth. cron.schedule('*/1 * * * *', async () => { try { - const results = await checkAll(load()); + const results = await checkAll(await services.listEnabled()); for (const r of results) await statusRepo.upsert(r); log.info({ n: results.length }, 'health check complete'); } catch (e) { log.error({ err: e }, 'health check failed'); } diff --git a/lib/db/migrations/015_monitored_services.sql b/lib/db/migrations/015_monitored_services.sql new file mode 100644 index 0000000..5fe013e --- /dev/null +++ b/lib/db/migrations/015_monitored_services.sql @@ -0,0 +1,20 @@ +-- 015_monitored_services.sql +-- DB-backed homelab service registry (replaces the hand-edited config/services.json). +-- Instance-wide (NOT space-scoped — these are infra services, not knowledge resources). +-- Live status stays in service_status, keyed by service_id = monitored_services.id. +CREATE TABLE monitored_services ( + id text PRIMARY KEY, -- stable slug, e.g. 'gitea' + name text NOT NULL, + category text NOT NULL DEFAULT 'other', + host text, + url text NOT NULL, + icon text, + check_cfg jsonb NOT NULL DEFAULT '{}'::jsonb, -- {type:'http'|'tcp', path?:'/...'} + source text NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual','discovered')), + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- Discovery reconciliation looks up by url to avoid re-adding an existing service. +CREATE INDEX idx_monitored_services_url ON monitored_services (url); diff --git a/lib/db/repos/monitored_services.js b/lib/db/repos/monitored_services.js new file mode 100644 index 0000000..dcff7ba --- /dev/null +++ b/lib/db/repos/monitored_services.js @@ -0,0 +1,85 @@ +import { pool } from '../pool.js'; + +const COLS = 'id, name, category, host, url, icon, check_cfg, source, enabled'; + +// Map a DB row to the service shape the registry/checker expect (check_cfg -> check). +function toSvc(r) { + return { + id: r.id, name: r.name, category: r.category, host: r.host, url: r.url, + icon: r.icon, check: r.check_cfg || {}, source: r.source, enabled: r.enabled + }; +} + +export async function listEnabled() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM monitored_services WHERE enabled ORDER BY category, name`); + return rows.map(toSvc); +} + +export async function all() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM monitored_services ORDER BY category, name`); + return rows.map(toSvc); +} + +// Discovered, not-yet-promoted candidates awaiting the owner's review. +export async function listDiscovered() { + const { rows } = await pool.query( + `SELECT ${COLS} FROM monitored_services WHERE source='discovered' AND NOT enabled ORDER BY name`); + return rows.map(toSvc); +} + +export async function get(id) { + const { rows: [r] } = await pool.query( + `SELECT ${COLS} FROM monitored_services WHERE id=$1`, [id]); + return r ? toSvc(r) : null; +} + +export async function count() { + const { rows: [r] } = await pool.query(`SELECT count(*)::int AS n FROM monitored_services`); + return r.n; +} + +export async function create(svc) { + const { id, name, category = 'other', host = null, url, icon = null, + check = {}, source = 'manual', enabled = true } = svc; + const { rows: [r] } = await pool.query( + `INSERT INTO monitored_services (id, name, category, host, url, icon, check_cfg, source, enabled) + VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9) RETURNING ${COLS}`, + [id, name, category, host, url, icon, JSON.stringify(check), source, enabled]); + return toSvc(r); +} + +const PATCHABLE = ['name', 'category', 'host', 'url', 'icon', 'enabled']; +export async function update(id, patch) { + const sets = [], vals = []; + for (const k of PATCHABLE) { + if (patch[k] !== undefined) { vals.push(patch[k]); sets.push(`${k}=$${vals.length}`); } + } + if (patch.check !== undefined) { vals.push(JSON.stringify(patch.check)); sets.push(`check_cfg=$${vals.length}::jsonb`); } + if (!sets.length) return get(id); + vals.push(id); + const { rows: [r] } = await pool.query( + `UPDATE monitored_services SET ${sets.join(', ')}, updated_at=now() WHERE id=$${vals.length} RETURNING ${COLS}`, + vals); + return r ? toSvc(r) : null; +} + +export async function remove(id) { + const { rowCount } = await pool.query(`DELETE FROM monitored_services WHERE id=$1`, [id]); + return rowCount > 0; +} + +// Insert a discovered candidate (disabled, source='discovered') unless a service +// with the same id OR url already exists — never clobbers a curated entry. +export async function upsertDiscovered(svc) { + const { id, name, category = 'other', host = null, url, icon = null, check = {} } = svc; + const { rows: [r] } = await pool.query( + `INSERT INTO monitored_services (id, name, category, host, url, icon, check_cfg, source, enabled) + SELECT $1,$2,$3,$4,$5,$6,$7::jsonb,'discovered',false + WHERE NOT EXISTS (SELECT 1 FROM monitored_services WHERE url=$5) + ON CONFLICT (id) DO NOTHING + RETURNING ${COLS}`, + [id, name, category, host, url, icon, JSON.stringify(check)]); + return r ? toSvc(r) : null; // null = already existed (skipped) +} diff --git a/lib/health/registry.js b/lib/health/registry.js index 86fcd6c..bfe46b9 100644 --- a/lib/health/registry.js +++ b/lib/health/registry.js @@ -1,22 +1,18 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as repo from '../db/repos/monitored_services.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG = path.join(__dirname, '../../config/services.json'); +const SEED_FILE = path.join(__dirname, '../../config/services.json'); export const CATEGORY_ORDER = ['agents', 'infrastructure', 'media', 'other']; -let cache = null; -export function load() { - if (!cache) cache = JSON.parse(readFileSync(CONFIG, 'utf8')); - return cache; -} -export function _reset() { cache = null; } // tests - +// Icon slug: explicit `icon`, else slugified name. Pure. export function iconSlug(svc) { return (svc.icon || svc.name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } +// Group services by category in CATEGORY_ORDER (unknown categories last). Pure. export function grouped(services) { const map = new Map(); for (const s of services) { @@ -28,3 +24,18 @@ export function grouped(services) { .filter(c => map.has(c)) .map(category => ({ category, services: map.get(category) })); } + +// One-time bootstrap: if the registry table is empty, populate it from the +// version-controlled config/services.json seed. Idempotent (no-op once seeded). +export async function seedFromConfig() { + if ((await repo.count()) > 0) return 0; + let seed; + try { seed = JSON.parse(readFileSync(SEED_FILE, 'utf8')); } + catch { return 0; } + let n = 0; + for (const s of seed) { + try { await repo.create({ ...s, source: 'manual', enabled: true }); n++; } + catch { /* skip a bad/duplicate seed row */ } + } + return n; +} diff --git a/lib/jobs/index.js b/lib/jobs/index.js index 07789bc..a71b937 100644 --- a/lib/jobs/index.js +++ b/lib/jobs/index.js @@ -6,8 +6,9 @@ import * as embed from './workers/embed.js'; import * as karakeep from './workers/karakeep.js'; import * as speedtest from './workers/speedtest.js'; import * as healthCheck from './workers/health_check.js'; +import * as discover from './workers/discover.js'; -const WORKERS = [echo, url, blob, embed, karakeep, speedtest, healthCheck]; +const WORKERS = [echo, url, blob, embed, karakeep, speedtest, healthCheck, discover]; export async function registerWorkers() { for (const w of WORKERS) { diff --git a/lib/jobs/workers/discover.js b/lib/jobs/workers/discover.js new file mode 100644 index 0000000..d300091 --- /dev/null +++ b/lib/jobs/workers/discover.js @@ -0,0 +1,72 @@ +import net from 'node:net'; +import * as services from '../../db/repos/monitored_services.js'; +import { log } from '../../log.js'; + +export const NAME = 'discover.lan'; + +// Common homelab web/service ports to probe. +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]; +const HTTPS_PORTS = new Set([443, 8443, 8006]); + +function tcpOpen(host, port, timeoutMs = 350) { + return new Promise(resolve => { + const sock = net.connect({ host, port }); + let done = false; + const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } }; + sock.setTimeout(timeoutMs); + sock.on('connect', () => finish(true)); + sock.on('timeout', () => finish(false)); + sock.on('error', () => finish(false)); + }); +} + +async function httpTitle(url) { + try { + const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2500) }); + let title = ''; + if (res.status >= 200 && res.status < 400) { + const html = await res.text().catch(() => ''); + const m = html.match(/([^<]{1,80})/i); + title = m ? m[1].trim().replace(/\s+/g, ' ') : ''; + } + return { code: res.status, title }; + } catch { return null; } +} + +// Test seam. +let _tcp = tcpOpen, _http = httpTitle; +export function _setProbes({ tcp, http } = {}) { _tcp = tcp || tcpOpen; _http = http || httpTitle; } + +async function mapPool(items, concurrency, fn) { + const out = new Array(items.length); + let i = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]); } + })); + return out; +} + +export async function handler(job) { + const subnet = job?.data?.subnet || process.env.DISCOVER_SUBNET || '192.168.1'; + const targets = []; + for (let h = 1; h <= 254; h++) for (const port of PORTS) targets.push({ host: `${subnet}.${h}`, port }); + + // 1) TCP sweep → live host:ports + 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) + let added = 0; + for (const { host, port } of open) { + const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http'; + const url = `${scheme}://${host}:${port}`; + const probe = await _http(url); + const name = (probe && probe.title) || `${host}:${port}`; + const id = `disc-${host.replace(/\./g, '-')}-${port}`; + const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' }; + const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check }); + if (r) added++; + } + log.info({ open: open.length, added }, 'lan discovery complete'); + return { open: open.length, added }; +} diff --git a/lib/jobs/workers/health_check.js b/lib/jobs/workers/health_check.js index 69ca6bc..13aa038 100644 --- a/lib/jobs/workers/health_check.js +++ b/lib/jobs/workers/health_check.js @@ -1,8 +1,8 @@ -import { load } from '../../health/registry.js'; import { checkAll } from '../../health/checker.js'; import * as statusRepo from '../../db/repos/service_status.js'; +import * as services from '../../db/repos/monitored_services.js'; export const NAME = 'health.check'; export async function handler(_job) { - const results = await checkAll(load()); + const results = await checkAll(await services.listEnabled()); for (const r of results) await statusRepo.upsert(r); } diff --git a/package.json b/package.json index 51434f9..f631d13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.0.0-alpha.10", + "version": "2.0.0-alpha.11", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index ff3932f..692b3c4 100644 --- a/public/style.css +++ b/public/style.css @@ -324,3 +324,12 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 10px; color: var(--muted); opacity: .7; } .dv-tile.flag { border-color: var(--bad); background: #1a1012; } .dv-tile.flag .dv-nm { color: var(--bad); } + +/* ===== Discovered services + scan (Plan: DB-backed registry) ===== */ +.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent); + border-radius: 5px; padding: 4px 12px; font-family: var(--font-ui); font-size: 11px; cursor: pointer; flex: none; } +.lb-scan:hover { background: var(--accent-dim); color: var(--text); } +.tile.disc { border-style: dashed; } +.disc-add { margin-left: auto; width: 22px; height: 22px; border-radius: 50%; flex: none; + border: 1px solid var(--accent-dim); background: transparent; color: var(--accent); font-size: 14px; line-height: 1; cursor: pointer; } +.disc-add:hover { background: var(--accent); color: var(--bg); } diff --git a/public/views/health_band.js b/public/views/health_band.js index f175ea7..b60a8ec 100644 --- a/public/views/health_band.js +++ b/public/views/health_band.js @@ -4,7 +4,36 @@ import { littleblueAvatar } from '../components/littleblue_avatar.js'; import { serviceTile } from '../components/service_tile.js'; const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' }; -let host, timer; +let host, timer, scanning = false; + +async function promote(id) { + try { await api.patch('/api/health/services/' + id, { enabled: true }); load(); } catch { /* */ } +} +function scan() { + if (scanning) return; + scanning = true; load(); // reflect "Scanning…" + api.post('/api/health/discover', {}).catch(() => { /* */ }); + setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s +} + +// Owner-only; returns a section element or null (skipped for non-owner / none). +async function discoveredSection() { + let cand; + try { cand = await api.get('/api/health/services/discovered'); } catch { return null; } + if (!cand || !cand.length) return null; + return el('div', { class: 'lb-section' }, + el('div', { class: 'lb-group' }, + el('span', { class: 'gname' }, 'Discovered'), + el('span', { class: 'gcount' }, `${cand.length} new`), + el('span', { class: 'line' })), + el('div', { class: 'tiles' }, cand.map(c => + el('div', { class: 'tile disc' }, + el('div', { class: 'tile-main' }, + el('div', { class: 'tile-nm' }, c.name), + el('div', { class: 'tile-host' }, c.url)), + el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+'))))); +} + async function load() { if (!host) return; try { @@ -16,11 +45,15 @@ async function load() { el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`), el('span', { class: 'line' })), el('div', { class: 'tiles' }, g.services.map(serviceTile)))); + const disc = await discoveredSection(); mount(host, el('div', { class: 'lbwrap' }, littleblueAvatar(), - el('div', {}, el('div', { class: 'lb-name' }, 'Little Blue'), - el('div', { class: 'lb-sub' }, 'Health & Uptime of the lab'))), - sections); + el('div', { style: { flex: 1 } }, + el('div', { class: 'lb-name' }, 'Little Blue'), + el('div', { class: 'lb-sub' }, 'Health & Uptime of the lab')), + el('button', { class: 'lb-scan', title: 'Scan the LAN for services', onclick: scan }, scanning ? 'Scanning…' : 'Scan')), + sections, + disc); } catch { mount(host, el('span', { class: 'muted' }, 'Health band unavailable')); } } export function renderHealthBand(el_) { host = el_; load(); timer = setInterval(load, 60000); } diff --git a/server.js b/server.js index 1071c8a..2032e5e 100644 --- a/server.js +++ b/server.js @@ -8,8 +8,9 @@ import { registerWorkers } from './lib/jobs/index.js'; import { router as ingestRouter } from './lib/api/routes/ingest.js'; import { router as iconsRouter } from './lib/api/routes/icons.js'; import { startCron } from './lib/cron/index.js'; +import { seedFromConfig } from './lib/health/registry.js'; -const VERSION = '2.0.0-alpha.10'; +const VERSION = '2.0.0-alpha.11'; export function createApp() { const app = express(); @@ -58,6 +59,9 @@ if (import.meta.url === `file://${process.argv[1]}`) { .then(() => log.info('job queue ready')) .catch(err => log.error({ err }, 'queue boot failed')); startCron(); + // One-time bootstrap of the service registry from config/services.json if empty. + seedFromConfig().then(n => { if (n) log.info({ seeded: n }, 'monitored_services seeded from config'); }) + .catch(err => log.error({ err }, 'service registry seed failed')); app.listen(port, () => log.info({ port }, 'void-server listening')); for (const sig of ['SIGTERM', 'SIGINT']) { process.on(sig, async () => { diff --git a/tests/api/health.test.js b/tests/api/health.test.js index 98ecb1f..325a185 100644 --- a/tests/api/health.test.js +++ b/tests/api/health.test.js @@ -1,24 +1,55 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import request from 'supertest'; import { setup } from './helpers.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; import * as statusRepo from '../../lib/db/repos/service_status.js'; +import * as services from '../../lib/db/repos/monitored_services.js'; let app, ownerHeaders; -beforeAll(async () => { - ({ app, ownerHeaders } = await setup()); +beforeAll(async () => { ({ app, ownerHeaders } = await setup()); }); +beforeEach(async () => { + await resetDb(); await migrateUp(); + await services.create({ id: 'gitea', name: 'Gitea', category: 'infrastructure', host: 'ct105', url: 'http://192.168.1.223:3000', icon: 'gitea', check: { type: 'http' } }); await statusRepo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 10, detail: '200' }); }); -describe('health api', () => { + +describe('health api (DB-backed registry)', () => { it('401 without auth', async () => expect((await request(app).get('/api/health/services')).status).toBe(401)); - it('POST /check rejects anonymous (owner-only mutation)', async () => - expect((await request(app).post('/api/health/check')).status).toBe(401)); - it('returns groups with counts + merged cached status', async () => { + it('POST /check rejects anonymous', async () => expect((await request(app).post('/api/health/check')).status).toBe(401)); + it('POST /discover rejects anonymous', async () => expect((await request(app).post('/api/health/discover')).status).toBe(401)); + it('POST /services rejects anonymous', async () => expect((await request(app).post('/api/health/services')).status).toBe(401)); + + it('GET /services returns grouped counts + merged status', async () => { const res = await request(app).get('/api/health/services').set(ownerHeaders); expect(res.status).toBe(200); const infra = res.body.find(g => g.category === 'infrastructure'); - expect(infra).toBeTruthy(); - expect(infra.healthy).toBeGreaterThanOrEqual(1); // gitea ok - const gitea = infra.services.find(s => s.id === 'gitea'); - expect(gitea.status).toBe('ok'); + expect(infra.healthy).toBe(1); + expect(infra.services.find(s => s.id === 'gitea').status).toBe('ok'); + }); + + it('POST /services adds a service that shows up in the band', async () => { + const create = await request(app).post('/api/health/services').set(ownerHeaders) + .send({ id: 'ollama', name: 'Ollama', category: 'agents', host: 'ct102', url: 'http://192.168.1.185:11434' }); + expect(create.status).toBe(201); + const res = await request(app).get('/api/health/services').set(ownerHeaders); + expect(res.body.find(g => g.category === 'agents').services.some(s => s.id === 'ollama')).toBe(true); + }); + + it('PATCH disables a service (drops out of the band); DELETE removes it', async () => { + await request(app).patch('/api/health/services/gitea').set(ownerHeaders).send({ enabled: false }); + let res = await request(app).get('/api/health/services').set(ownerHeaders); + expect(res.body.find(g => g.category === 'infrastructure')).toBeUndefined(); // no enabled infra now + const del = await request(app).delete('/api/health/services/gitea').set(ownerHeaders); + expect(del.status).toBe(204); + expect((await request(app).delete('/api/health/services/gitea').set(ownerHeaders)).status).toBe(404); + }); + + it('GET /services/discovered lists owner-only candidates', async () => { + await services.upsertDiscovered({ id: 'disc-x', name: 'Mystery', url: 'http://192.168.1.99:8000' }); + expect((await request(app).get('/api/health/services/discovered')).status).toBe(401); // anon + const res = await request(app).get('/api/health/services/discovered').set(ownerHeaders); + expect(res.status).toBe(200); + expect(res.body.map(s => s.id)).toContain('disc-x'); }); }); diff --git a/tests/health/registry.test.js b/tests/health/registry.test.js index d7ddec8..4bdf933 100644 --- a/tests/health/registry.test.js +++ b/tests/health/registry.test.js @@ -1,17 +1,33 @@ -import { describe, it, expect } from 'vitest'; -import { load, grouped, iconSlug, CATEGORY_ORDER } from '../../lib/health/registry.js'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { grouped, iconSlug, CATEGORY_ORDER, seedFromConfig } from '../../lib/health/registry.js'; +import * as services from '../../lib/db/repos/monitored_services.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('registry', () => { - it('loads the seed config', () => { expect(load().length).toBeGreaterThan(0); }); - it('derives an icon slug from icon or name', () => { + it('iconSlug derives from icon or name', () => { expect(iconSlug({ name: 'Open WebUI' })).toBe('open-webui'); expect(iconSlug({ name: 'Plex', icon: 'plex' })).toBe('plex'); }); - it('groups in agents→infrastructure→media order', () => { - const g = grouped(load()); + + it('grouped orders agents→infrastructure→media; unknown categories fold into "other" (last)', () => { + const g = grouped([{ category: 'media', name: 'a' }, { category: 'agents', name: 'b' }, { category: 'zzz', name: 'c' }]); const cats = g.map(x => x.category); - const ai = cats.indexOf('agents'), mi = cats.indexOf('media'); - expect(ai).toBeLessThan(mi); + expect(cats.indexOf('agents')).toBeLessThan(cats.indexOf('media')); + expect(cats[cats.length - 1]).toBe('other'); // 'zzz' normalized to 'other' + expect(g.find(x => x.category === 'other').services[0].name).toBe('c'); expect(CATEGORY_ORDER[0]).toBe('agents'); }); + + it('seedFromConfig populates the DB from config/services.json once (idempotent)', async () => { + const n = await seedFromConfig(); + expect(n).toBeGreaterThan(0); + const after = await services.all(); + expect(after.length).toBe(n); + expect(after.every(s => s.source === 'manual' && s.enabled)).toBe(true); + expect(await seedFromConfig()).toBe(0); // table not empty → no-op + }); }); diff --git a/tests/jobs/discover.test.js b/tests/jobs/discover.test.js new file mode 100644 index 0000000..5965567 --- /dev/null +++ b/tests/jobs/discover.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as worker from '../../lib/jobs/workers/discover.js'; +import * as services from '../../lib/db/repos/monitored_services.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); +afterEach(() => worker._setProbes()); // restore real probes + +describe('discover.lan worker', () => { + it('upserts disabled discovered candidates for live HTTP host:ports', async () => { + // Fake: only .50:3000 and .60:8096 are "open". + const tcp = vi.fn(async (host, port) => + (host === '192.168.1.50' && port === 3000) || (host === '192.168.1.60' && port === 8096)); + const http = vi.fn(async (url) => ({ code: 200, title: url.includes('8096') ? 'Jellyfin' : 'Gitea' })); + worker._setProbes({ tcp, http }); + + const out = await worker.handler({ data: { subnet: '192.168.1' } }); + expect(out.open).toBe(2); + expect(out.added).toBe(2); + + const disc = await services.listDiscovered(); + expect(disc.map(s => s.name).sort()).toEqual(['Gitea', 'Jellyfin']); + expect(disc.every(s => s.source === 'discovered' && s.enabled === false)).toBe(true); + expect(await services.listEnabled()).toHaveLength(0); // candidates don't auto-join the band + }); + + it('does not re-add a service whose url already exists (manual wins, idempotent)', async () => { + await services.create({ id: 'plex', name: 'Plex', category: 'media', url: 'http://192.168.1.50:32400', check: { type: 'http' } }); + const tcp = vi.fn(async (host, port) => host === '192.168.1.50' && port === 32400); + const http = vi.fn(async () => ({ code: 200, title: 'Plex' })); + worker._setProbes({ tcp, http }); + + const out = await worker.handler({ data: { subnet: '192.168.1' } }); + expect(out.open).toBe(1); + expect(out.added).toBe(0); // url already known → skipped + expect(await services.listDiscovered()).toHaveLength(0); + }); +}); diff --git a/tests/repos/monitored_services.test.js b/tests/repos/monitored_services.test.js new file mode 100644 index 0000000..ede9f9b --- /dev/null +++ b/tests/repos/monitored_services.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as repo from '../../lib/db/repos/monitored_services.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +const gitea = { id: 'gitea', name: 'Gitea', category: 'infrastructure', host: 'ct105', url: 'http://192.168.1.223:3000', icon: 'gitea', check: { type: 'http' } }; + +describe('monitored_services repo', () => { + it('creates and lists enabled with check_cfg mapped to check', async () => { + await repo.create(gitea); + const list = await repo.listEnabled(); + expect(list).toHaveLength(1); + expect(list[0].id).toBe('gitea'); + expect(list[0].check).toEqual({ type: 'http' }); // check_cfg -> check + expect(list[0].source).toBe('manual'); + }); + + it('update patches fields (incl. check + enabled)', async () => { + await repo.create(gitea); + await repo.update('gitea', { name: 'Gitea CE', enabled: false, check: { type: 'tcp' } }); + expect((await repo.get('gitea')).name).toBe('Gitea CE'); + expect(await repo.listEnabled()).toHaveLength(0); // now disabled + expect((await repo.get('gitea')).check).toEqual({ type: 'tcp' }); + }); + + it('remove deletes', async () => { + await repo.create(gitea); + expect(await repo.remove('gitea')).toBe(true); + expect(await repo.get('gitea')).toBeNull(); + }); + + it('upsertDiscovered adds a disabled candidate, but never clobbers an existing url/id', async () => { + await repo.create(gitea); // manual, enabled + // same url -> skipped + expect(await repo.upsertDiscovered({ id: 'gitea-x', name: 'g', url: gitea.url })).toBeNull(); + // new url -> inserted, disabled, source=discovered + const d = await repo.upsertDiscovered({ id: 'plex', name: 'Plex?', url: 'http://192.168.1.230:32400' }); + expect(d.source).toBe('discovered'); + expect(d.enabled).toBe(false); + expect(await repo.listEnabled()).toHaveLength(1); // only gitea + expect(await repo.listDiscovered()).toHaveLength(1); // plex candidate + // re-running discovery is idempotent (same id -> skipped) + expect(await repo.upsertDiscovered({ id: 'plex', name: 'Plex?', url: 'http://192.168.1.230:32400' })).toBeNull(); + }); +});