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:
root
2026-06-04 07:55:08 +10:00
parent b728696020
commit ce26895d8e
17 changed files with 466 additions and 46 deletions

View 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)
}