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();
+ });
+});