diff --git a/lib/api/index.js b/lib/api/index.js index 882a221..bb2c631 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -27,6 +27,7 @@ import { router as dashboardRouter } from './routes/dashboard.js'; import { router as weatherRouter } from './routes/weather.js'; import { router as hostRouter } from './routes/host.js'; import { router as speedtestRouter } from './routes/speedtest.js'; +import { router as healthRouter } from './routes/health.js'; export function mountApi(app) { const api = Router(); @@ -61,6 +62,7 @@ export function mountApi(app) { api.use('/weather', weatherRouter); api.use('/host', hostRouter); api.use('/speedtest', speedtestRouter); + api.use('/health', healthRouter); api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter); api.use((_req, _res, next) => next(new NotFoundError('route not found'))); diff --git a/lib/api/routes/health.js b/lib/api/routes/health.js new file mode 100644 index 0000000..f71ae6c --- /dev/null +++ b/lib/api/routes/health.js @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import { asyncWrap } from '../errors.js'; +import { requireOwner } from '../cap.js'; +import { load, grouped, iconSlug } from '../../health/registry.js'; +import * as statusRepo from '../../db/repos/service_status.js'; +import { enqueue } from '../../jobs/queue.js'; + +export const router = Router(); + +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 st = statuses[s.id]; + return { + id: s.id, name: s.name, host: s.host, url: s.url, 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: services.filter(s => s.status === 'ok').length, + total: services.length, services }; + }); + res.json(groups); +})); + +router.post('/check', requireOwner, asyncWrap(async (_req, res) => { + const id = await enqueue('health.check', {}); + res.status(202).json({ enqueued: id }); +})); diff --git a/lib/jobs/index.js b/lib/jobs/index.js index 8418c1f..07789bc 100644 --- a/lib/jobs/index.js +++ b/lib/jobs/index.js @@ -5,8 +5,9 @@ import * as blob from './workers/blob.js'; import * as embed from './workers/embed.js'; import * as karakeep from './workers/karakeep.js'; import * as speedtest from './workers/speedtest.js'; +import * as healthCheck from './workers/health_check.js'; -const WORKERS = [echo, url, blob, embed, karakeep, speedtest]; +const WORKERS = [echo, url, blob, embed, karakeep, speedtest, healthCheck]; export async function registerWorkers() { for (const w of WORKERS) { diff --git a/lib/jobs/workers/health_check.js b/lib/jobs/workers/health_check.js new file mode 100644 index 0000000..69ca6bc --- /dev/null +++ b/lib/jobs/workers/health_check.js @@ -0,0 +1,8 @@ +import { load } from '../../health/registry.js'; +import { checkAll } from '../../health/checker.js'; +import * as statusRepo from '../../db/repos/service_status.js'; +export const NAME = 'health.check'; +export async function handler(_job) { + const results = await checkAll(load()); + for (const r of results) await statusRepo.upsert(r); +} diff --git a/tests/api/health.test.js b/tests/api/health.test.js new file mode 100644 index 0000000..3aaae7b --- /dev/null +++ b/tests/api/health.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import * as statusRepo from '../../lib/db/repos/service_status.js'; + +let app, ownerHeaders; +beforeAll(async () => { + ({ app, ownerHeaders } = await setup()); + await statusRepo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 10, detail: '200' }); +}); +describe('health api', () => { + it('401 without auth', async () => expect((await request(app).get('/api/health/services')).status).toBe(401)); + it('returns groups with counts + merged cached 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'); + }); +});