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:
@@ -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', {}) });
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user