Files
Void-Homelab/lib/api/routes/health.js
root 15de56dbe6 feat(littleblue): discovered services show matching network-device name
Cross-references each candidate host IP with lan_devices (known) so a tile shows
e.g. 'H Tower' instead of '192.168.1.15:32400'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:45:56 +10:00

83 lines
3.8 KiB
JavaScript

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 devices from '../../db/repos/lan_devices.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) => {
// Cross-reference each candidate's host IP with the Network Devices band so the
// tile can show a known device name instead of a bare IP:port.
const byIp = Object.fromEntries(
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
res.json((await services.listDiscovered()).map(s => ({
...s, icon: iconSlug(s), device: byIp[s.host] || null
})));
}));
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', {}) });
}));