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,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
40
tests/jobs/discover.test.js
Normal file
40
tests/jobs/discover.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
48
tests/repos/monitored_services.test.js
Normal file
48
tests/repos/monitored_services.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user