import { Router } from 'express'; import { z } from 'zod'; import { asyncWrap } from '../errors.js'; import { requireOwner } from '../cap.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(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, external: s.external ?? null, icon: iconSlug(s), status: st?.status || 'unknown', latency_ms: st?.latency_ms ?? null, detail: st?.detail || null, checked_at: st?.checked_at || null }; }); return { category: g.category, healthy: list.filter(s => s.status === 'ok').length, total: list.length, services: list }; }); res.json(groups); })); // 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(), external: z.string().url().optional(), 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', {}) }); }));