feat: 2.0.0-alpha.11 — DB-backed service registry + LAN auto-discovery
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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', {}) });
|
||||
}));
|
||||
|
||||
@@ -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'); }
|
||||
|
||||
20
lib/db/migrations/015_monitored_services.sql
Normal file
20
lib/db/migrations/015_monitored_services.sql
Normal file
@@ -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);
|
||||
85
lib/db/repos/monitored_services.js
Normal file
85
lib/db/repos/monitored_services.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
72
lib/jobs/workers/discover.js
Normal file
72
lib/jobs/workers/discover.js
Normal file
@@ -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(/<title>([^<]{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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.0.0-alpha.11",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
40
tests/jobs/discover.test.js
Normal file
40
tests/jobs/discover.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
48
tests/repos/monitored_services.test.js
Normal file
48
tests/repos/monitored_services.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user