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

@@ -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', {}) });
}));