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,24 +1,55 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as statusRepo from '../../lib/db/repos/service_status.js';
import * as services from '../../lib/db/repos/monitored_services.js';
let app, ownerHeaders;
beforeAll(async () => {
({ app, ownerHeaders } = await setup());
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
beforeEach(async () => {
await resetDb(); await migrateUp();
await services.create({ id: 'gitea', name: 'Gitea', category: 'infrastructure', host: 'ct105', url: 'http://192.168.1.223:3000', icon: 'gitea', check: { type: 'http' } });
await statusRepo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 10, detail: '200' });
});
describe('health api', () => {
describe('health api (DB-backed registry)', () => {
it('401 without auth', async () => expect((await request(app).get('/api/health/services')).status).toBe(401));
it('POST /check rejects anonymous (owner-only mutation)', async () =>
expect((await request(app).post('/api/health/check')).status).toBe(401));
it('returns groups with counts + merged cached status', async () => {
it('POST /check rejects anonymous', async () => expect((await request(app).post('/api/health/check')).status).toBe(401));
it('POST /discover rejects anonymous', async () => expect((await request(app).post('/api/health/discover')).status).toBe(401));
it('POST /services rejects anonymous', async () => expect((await request(app).post('/api/health/services')).status).toBe(401));
it('GET /services returns grouped counts + merged status', async () => {
const res = await request(app).get('/api/health/services').set(ownerHeaders);
expect(res.status).toBe(200);
const infra = res.body.find(g => g.category === 'infrastructure');
expect(infra).toBeTruthy();
expect(infra.healthy).toBeGreaterThanOrEqual(1); // gitea ok
const gitea = infra.services.find(s => s.id === 'gitea');
expect(gitea.status).toBe('ok');
expect(infra.healthy).toBe(1);
expect(infra.services.find(s => s.id === 'gitea').status).toBe('ok');
});
it('POST /services adds a service that shows up in the band', async () => {
const create = await request(app).post('/api/health/services').set(ownerHeaders)
.send({ id: 'ollama', name: 'Ollama', category: 'agents', host: 'ct102', url: 'http://192.168.1.185:11434' });
expect(create.status).toBe(201);
const res = await request(app).get('/api/health/services').set(ownerHeaders);
expect(res.body.find(g => g.category === 'agents').services.some(s => s.id === 'ollama')).toBe(true);
});
it('PATCH disables a service (drops out of the band); DELETE removes it', async () => {
await request(app).patch('/api/health/services/gitea').set(ownerHeaders).send({ enabled: false });
let res = await request(app).get('/api/health/services').set(ownerHeaders);
expect(res.body.find(g => g.category === 'infrastructure')).toBeUndefined(); // no enabled infra now
const del = await request(app).delete('/api/health/services/gitea').set(ownerHeaders);
expect(del.status).toBe(204);
expect((await request(app).delete('/api/health/services/gitea').set(ownerHeaders)).status).toBe(404);
});
it('GET /services/discovered lists owner-only candidates', async () => {
await services.upsertDiscovered({ id: 'disc-x', name: 'Mystery', url: 'http://192.168.1.99:8000' });
expect((await request(app).get('/api/health/services/discovered')).status).toBe(401); // anon
const res = await request(app).get('/api/health/services/discovered').set(ownerHeaders);
expect(res.status).toBe(200);
expect(res.body.map(s => s.id)).toContain('disc-x');
});
});

View File

@@ -1,17 +1,33 @@
import { describe, it, expect } from 'vitest';
import { load, grouped, iconSlug, CATEGORY_ORDER } from '../../lib/health/registry.js';
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { grouped, iconSlug, CATEGORY_ORDER, seedFromConfig } from '../../lib/health/registry.js';
import * as services from '../../lib/db/repos/monitored_services.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
beforeEach(async () => { await resetDb(); await migrateUp(); });
describe('registry', () => {
it('loads the seed config', () => { expect(load().length).toBeGreaterThan(0); });
it('derives an icon slug from icon or name', () => {
it('iconSlug derives from icon or name', () => {
expect(iconSlug({ name: 'Open WebUI' })).toBe('open-webui');
expect(iconSlug({ name: 'Plex', icon: 'plex' })).toBe('plex');
});
it('groups in agents→infrastructure→media order', () => {
const g = grouped(load());
it('grouped orders agents→infrastructure→media; unknown categories fold into "other" (last)', () => {
const g = grouped([{ category: 'media', name: 'a' }, { category: 'agents', name: 'b' }, { category: 'zzz', name: 'c' }]);
const cats = g.map(x => x.category);
const ai = cats.indexOf('agents'), mi = cats.indexOf('media');
expect(ai).toBeLessThan(mi);
expect(cats.indexOf('agents')).toBeLessThan(cats.indexOf('media'));
expect(cats[cats.length - 1]).toBe('other'); // 'zzz' normalized to 'other'
expect(g.find(x => x.category === 'other').services[0].name).toBe('c');
expect(CATEGORY_ORDER[0]).toBe('agents');
});
it('seedFromConfig populates the DB from config/services.json once (idempotent)', async () => {
const n = await seedFromConfig();
expect(n).toBeGreaterThan(0);
const after = await services.all();
expect(after.length).toBe(n);
expect(after.every(s => s.source === 'manual' && s.enabled)).toBe(true);
expect(await seedFromConfig()).toBe(0); // table not empty → no-op
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as worker from '../../lib/jobs/workers/discover.js';
import * as services from '../../lib/db/repos/monitored_services.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
beforeEach(async () => { await resetDb(); await migrateUp(); });
afterEach(() => worker._setProbes()); // restore real probes
describe('discover.lan worker', () => {
it('upserts disabled discovered candidates for live HTTP host:ports', async () => {
// Fake: only .50:3000 and .60:8096 are "open".
const tcp = vi.fn(async (host, port) =>
(host === '192.168.1.50' && port === 3000) || (host === '192.168.1.60' && port === 8096));
const http = vi.fn(async (url) => ({ code: 200, title: url.includes('8096') ? 'Jellyfin' : 'Gitea' }));
worker._setProbes({ tcp, http });
const out = await worker.handler({ data: { subnet: '192.168.1' } });
expect(out.open).toBe(2);
expect(out.added).toBe(2);
const disc = await services.listDiscovered();
expect(disc.map(s => s.name).sort()).toEqual(['Gitea', 'Jellyfin']);
expect(disc.every(s => s.source === 'discovered' && s.enabled === false)).toBe(true);
expect(await services.listEnabled()).toHaveLength(0); // candidates don't auto-join the band
});
it('does not re-add a service whose url already exists (manual wins, idempotent)', async () => {
await services.create({ id: 'plex', name: 'Plex', category: 'media', url: 'http://192.168.1.50:32400', check: { type: 'http' } });
const tcp = vi.fn(async (host, port) => host === '192.168.1.50' && port === 32400);
const http = vi.fn(async () => ({ code: 200, title: 'Plex' }));
worker._setProbes({ tcp, http });
const out = await worker.handler({ data: { subnet: '192.168.1' } });
expect(out.open).toBe(1);
expect(out.added).toBe(0); // url already known → skipped
expect(await services.listDiscovered()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as repo from '../../lib/db/repos/monitored_services.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
beforeEach(async () => { await resetDb(); await migrateUp(); });
const gitea = { id: 'gitea', name: 'Gitea', category: 'infrastructure', host: 'ct105', url: 'http://192.168.1.223:3000', icon: 'gitea', check: { type: 'http' } };
describe('monitored_services repo', () => {
it('creates and lists enabled with check_cfg mapped to check', async () => {
await repo.create(gitea);
const list = await repo.listEnabled();
expect(list).toHaveLength(1);
expect(list[0].id).toBe('gitea');
expect(list[0].check).toEqual({ type: 'http' }); // check_cfg -> check
expect(list[0].source).toBe('manual');
});
it('update patches fields (incl. check + enabled)', async () => {
await repo.create(gitea);
await repo.update('gitea', { name: 'Gitea CE', enabled: false, check: { type: 'tcp' } });
expect((await repo.get('gitea')).name).toBe('Gitea CE');
expect(await repo.listEnabled()).toHaveLength(0); // now disabled
expect((await repo.get('gitea')).check).toEqual({ type: 'tcp' });
});
it('remove deletes', async () => {
await repo.create(gitea);
expect(await repo.remove('gitea')).toBe(true);
expect(await repo.get('gitea')).toBeNull();
});
it('upsertDiscovered adds a disabled candidate, but never clobbers an existing url/id', async () => {
await repo.create(gitea); // manual, enabled
// same url -> skipped
expect(await repo.upsertDiscovered({ id: 'gitea-x', name: 'g', url: gitea.url })).toBeNull();
// new url -> inserted, disabled, source=discovered
const d = await repo.upsertDiscovered({ id: 'plex', name: 'Plex?', url: 'http://192.168.1.230:32400' });
expect(d.source).toBe('discovered');
expect(d.enabled).toBe(false);
expect(await repo.listEnabled()).toHaveLength(1); // only gitea
expect(await repo.listDiscovered()).toHaveLength(1); // plex candidate
// re-running discovery is idempotent (same id -> skipped)
expect(await repo.upsertDiscovered({ id: 'plex', name: 'Plex?', url: 'http://192.168.1.230:32400' })).toBeNull();
});
});